refactor(editor): rx state management in turbo renderer (#11200)

# Refactor Turbo Renderer State Management to Use RxJS

### TL;DR

Refactored the TurboRenderer state management to use RxJS observables instead of direct state mutations, improving state transitions and reactivity.

### What changed?

- Replaced the public `state` property with a private `state$` BehaviorSubject in `ViewportTurboRendererExtension`
- Added proper state transition logging using RxJS operators
- Combined multiple event subscriptions using `merge` operator for better organization
- Improved state transition logic in the `refresh()` method
- Updated the `zooming$` and `panning$` signals in the Viewport

### Why make this change?

This refactoring improves the codebase by:
1. Using a more consistent reactive programming model with RxJS
2. Making state transitions more explicit and traceable
3. Reducing potential bugs from manual state management
4. Improving code organization by combining related event streams
5. Ensuring proper cleanup of resources when components are disposed

The change maintains the same functionality while making the code more maintainable and the state management more robust.
This commit is contained in:
doodlewind
2025-03-27 04:50:32 +00:00
parent 22ef32f5c2
commit bb1bbccd0f
4 changed files with 88 additions and 69 deletions

View File

@@ -13,8 +13,6 @@
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/store": "workspace:*",
"@types/lodash-es": "^4.17.12",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.1"
},
"exports": {

View File

@@ -7,7 +7,15 @@ import {
} from '@blocksuite/block-std/gfx';
import type { Container } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import debounce from 'lodash-es/debounce';
import {
BehaviorSubject,
distinctUntilChanged,
merge,
Subject,
take,
tap,
} from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import {
debugLog,
@@ -37,7 +45,7 @@ export const TurboRendererConfigFactory =
export class ViewportTurboRendererExtension extends GfxExtension {
static override key = 'viewportTurboRenderer';
public state: RenderingState = 'inactive';
private readonly state$ = new BehaviorSubject<RenderingState>('inactive');
public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
private readonly worker: Worker;
private readonly disposables = new DisposableGroup();
@@ -45,6 +53,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private layoutVersion = 0;
private bitmap: ImageBitmap | null = null;
private viewportElement: GfxViewportElement | null = null;
private readonly refresh$ = new Subject<void>();
constructor(gfx: GfxController) {
super(gfx);
@@ -55,6 +64,21 @@ export class ViewportTurboRendererExtension extends GfxExtension {
throw new Error('TurboRendererConfig not found');
}
this.worker = config.painterWorkerEntry();
// Set up state change logging
this.state$
.pipe(
distinctUntilChanged(),
tap(state => this.debugLog(`State changed to: ${state}`))
)
.subscribe();
// Set up debounced refresh
this.refresh$
.pipe(debounceTime(this.options.debounceTime))
.subscribe(() => {
this.refresh().catch(console.error);
});
}
static override extendGfx(gfx: GfxController) {
@@ -84,39 +108,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
mountPoint.append(this.canvas);
}
const subscription = this.viewport.elementReady.subscribe(element => {
subscription.unsubscribe();
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
this.viewportElement = element;
syncCanvasSize(this.canvas, this.std.host);
this.setState('pending');
this.state$.next('pending');
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => this.handleResize())
);
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this.refresh().catch(console.error);
})
);
this.disposables.add({
dispose: this.viewport.zooming$.subscribe(isZooming => {
this.debugLog(`Zooming signal changed: ${isZooming}`);
if (isZooming) {
this.setState('zooming');
} else if (this.state === 'zooming') {
this.setState('pending');
this.refresh().catch(console.error);
}
}),
});
this.disposables.add(
this.viewport.zooming$
.pipe(
tap(isZooming => {
this.debugLog(`Zooming signal changed: ${isZooming}`);
if (isZooming) {
this.state$.next('zooming');
} else if (this.state$.value === 'zooming') {
this.state$.next('pending');
this.refresh().catch(console.error);
}
})
)
.subscribe()
);
});
// Handle selection and block updates
const selectionUpdates$ = this.selection.slots.updated;
const blockUpdates$ = this.std.store.slots.blockUpdated;
// Combine all events that should trigger invalidation
this.disposables.add(
this.selection.slots.updated.subscribe(() => this.invalidate())
);
this.disposables.add(
this.std.store.slots.blockUpdated.subscribe(() => this.invalidate())
merge(selectionUpdates$, blockUpdates$).subscribe(() => this.invalidate())
);
}
@@ -127,7 +157,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.worker.terminate();
this.canvas.remove();
this.disposables.dispose();
this.setState('inactive');
this.state$.next('inactive');
this.state$.complete();
this.refresh$.complete();
}
get viewport() {
@@ -146,41 +178,40 @@ export class ViewportTurboRendererExtension extends GfxExtension {
}
async refresh() {
if (this.state === 'inactive') return;
if (this.state$.value === 'inactive') return;
this.clearCanvas();
// -> pending
// Determine the next state based on current conditions
let nextState: RenderingState;
if (this.viewport.zoom > this.options.zoomThreshold) {
this.debugLog('Zoom above threshold, falling back to DOM rendering');
this.setState('pending');
nextState = 'pending';
this.clearOptimizedBlocks();
}
// -> zooming
else if (this.isZooming()) {
} else if (this.isZooming()) {
this.debugLog('Currently zooming, using placeholder rendering');
this.setState('zooming');
nextState = 'zooming';
this.paintPlaceholder();
this.updateOptimizedBlocks();
}
// -> ready
else if (this.canUseBitmapCache()) {
} else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap');
this.setState('ready');
nextState = 'ready';
this.drawCachedBitmap();
this.updateOptimizedBlocks();
}
// -> rendering
else {
this.setState('rendering');
} else {
this.debugLog('Starting bitmap rendering');
nextState = 'rendering';
this.state$.next(nextState);
await this.paintLayout();
this.drawCachedBitmap();
this.updateOptimizedBlocks();
// After rendering completes, transition to ready state
nextState = 'ready';
}
}
debouncedRefresh = debounce(() => {
this.refresh().catch(console.error);
}, this.options.debounceTime);
this.state$.next(nextState);
}
invalidate() {
this.layoutVersion++;
@@ -188,13 +219,13 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.clearBitmap();
this.clearCanvas();
this.clearOptimizedBlocks();
this.setState('pending');
this.state$.next('pending');
this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`);
}
private debugLog(message: string) {
if (!debug) return;
debugLog(message, this.state);
debugLog(message, this.state$.value);
}
private clearBitmap() {
@@ -234,21 +265,21 @@ export class ViewportTurboRendererExtension extends GfxExtension {
);
this.clearBitmap();
this.bitmap = e.data.bitmap;
this.setState('ready');
this.state$.next('ready');
resolve();
} else {
this.debugLog(
`Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})`
);
e.data.bitmap.close();
this.setState('pending');
this.state$.next('pending');
resolve();
}
} else if (e.data.type === 'paintError') {
this.debugLog(
`Paint error: ${e.data.error} for blockType: ${e.data.blockType}`
);
this.setState('pending');
this.state$.next('pending');
resolve();
}
};
@@ -275,7 +306,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private drawCachedBitmap() {
if (!this.bitmap) {
this.debugLog('No cached bitmap available, requesting refresh');
this.debouncedRefresh();
this.refresh$.next();
return;
}
@@ -301,17 +332,11 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.debugLog('Bitmap drawn to canvas');
}
setState(newState: RenderingState) {
if (this.state === newState) return;
this.state = newState;
this.debugLog(`State change: ${this.state} -> ${newState}`);
}
private canOptimize(): boolean {
const isBelowZoomThreshold =
this.viewport.zoom <= this.options.zoomThreshold;
return (
(this.state === 'ready' || this.state === 'zooming') &&
(this.state$.value === 'ready' || this.state$.value === 'zooming') &&
isBelowZoomThreshold
);
}
@@ -335,7 +360,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host);
this.invalidate();
this.debouncedRefresh();
this.refresh$.next();
}
private paintPlaceholder() {

View File

@@ -5,9 +5,8 @@ import {
type IVec,
Vec,
} from '@blocksuite/global/gfx';
import { signal } from '@preact/signals-core';
import debounce from 'lodash-es/debounce';
import { debounceTime, Subject } from 'rxjs';
import { BehaviorSubject, debounceTime, Subject } from 'rxjs';
import type { GfxViewportElement } from '.';
@@ -100,19 +99,19 @@ export class Viewport {
center: IVec;
}>();
zooming$ = signal(false);
panning$ = signal(false);
zooming$ = new BehaviorSubject<boolean>(false);
panning$ = new BehaviorSubject<boolean>(false);
ZOOM_MAX = ZOOM_MAX;
ZOOM_MIN = ZOOM_MIN;
private readonly _resetZooming = debounce(() => {
this.zooming$.value = false;
this.zooming$.next(false);
}, 200);
private readonly _resetPanning = debounce(() => {
this.panning$.value = false;
this.panning$.next(false);
}, 200);
constructor() {
@@ -296,9 +295,8 @@ export class Viewport {
this.viewportMoved.complete();
this.viewportUpdated.complete();
this._resizeSubject.complete();
this.zooming$.value = false;
this.panning$.value = false;
this.zooming$.complete();
this.panning$.complete();
}
getFitToScreenData(
@@ -371,7 +369,7 @@ export class Viewport {
this._center.x = centerX;
this._center.y = centerY;
this.panning$.value = true;
this.panning$.next(true);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
@@ -530,7 +528,7 @@ export class Viewport {
Vec.mul(offset, prevZoom / newZoom)
);
if (wheel) {
this.zooming$.value = true;
this.zooming$.next(true);
}
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
this.viewportUpdated.next({