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
@@ -13,8 +13,6 @@
"@blocksuite/block-std": "workspace:*", "@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*", "@blocksuite/global": "workspace:*",
"@blocksuite/store": "workspace:*", "@blocksuite/store": "workspace:*",
"@types/lodash-es": "^4.17.12",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"exports": { "exports": {
@@ -7,7 +7,15 @@ import {
} from '@blocksuite/block-std/gfx'; } from '@blocksuite/block-std/gfx';
import type { Container } from '@blocksuite/global/di'; import type { Container } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable'; 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 { import {
debugLog, debugLog,
@@ -37,7 +45,7 @@ export const TurboRendererConfigFactory =
export class ViewportTurboRendererExtension extends GfxExtension { export class ViewportTurboRendererExtension extends GfxExtension {
static override key = 'viewportTurboRenderer'; static override key = 'viewportTurboRenderer';
public state: RenderingState = 'inactive'; private readonly state$ = new BehaviorSubject<RenderingState>('inactive');
public readonly canvas: HTMLCanvasElement = document.createElement('canvas'); public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
private readonly worker: Worker; private readonly worker: Worker;
private readonly disposables = new DisposableGroup(); private readonly disposables = new DisposableGroup();
@@ -45,6 +53,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private layoutVersion = 0; private layoutVersion = 0;
private bitmap: ImageBitmap | null = null; private bitmap: ImageBitmap | null = null;
private viewportElement: GfxViewportElement | null = null; private viewportElement: GfxViewportElement | null = null;
private readonly refresh$ = new Subject<void>();
constructor(gfx: GfxController) { constructor(gfx: GfxController) {
super(gfx); super(gfx);
@@ -55,6 +64,21 @@ export class ViewportTurboRendererExtension extends GfxExtension {
throw new Error('TurboRendererConfig not found'); throw new Error('TurboRendererConfig not found');
} }
this.worker = config.painterWorkerEntry(); 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) { static override extendGfx(gfx: GfxController) {
@@ -84,39 +108,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
mountPoint.append(this.canvas); mountPoint.append(this.canvas);
} }
const subscription = this.viewport.elementReady.subscribe(element => { this.viewport.elementReady.pipe(take(1)).subscribe(element => {
subscription.unsubscribe();
this.viewportElement = element; this.viewportElement = element;
syncCanvasSize(this.canvas, this.std.host); syncCanvasSize(this.canvas, this.std.host);
this.setState('pending'); this.state$.next('pending');
this.disposables.add( this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => this.handleResize()) this.viewport.sizeUpdated.subscribe(() => this.handleResize())
); );
this.disposables.add( this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => { this.viewport.viewportUpdated.subscribe(() => {
this.refresh().catch(console.error); this.refresh().catch(console.error);
}) })
); );
this.disposables.add({ this.disposables.add(
dispose: this.viewport.zooming$.subscribe(isZooming => { this.viewport.zooming$
this.debugLog(`Zooming signal changed: ${isZooming}`); .pipe(
if (isZooming) { tap(isZooming => {
this.setState('zooming'); this.debugLog(`Zooming signal changed: ${isZooming}`);
} else if (this.state === 'zooming') { if (isZooming) {
this.setState('pending'); this.state$.next('zooming');
this.refresh().catch(console.error); } 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.disposables.add(
this.selection.slots.updated.subscribe(() => this.invalidate()) merge(selectionUpdates$, blockUpdates$).subscribe(() => this.invalidate())
);
this.disposables.add(
this.std.store.slots.blockUpdated.subscribe(() => this.invalidate())
); );
} }
@@ -127,7 +157,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.worker.terminate(); this.worker.terminate();
this.canvas.remove(); this.canvas.remove();
this.disposables.dispose(); this.disposables.dispose();
this.setState('inactive'); this.state$.next('inactive');
this.state$.complete();
this.refresh$.complete();
} }
get viewport() { get viewport() {
@@ -146,41 +178,40 @@ export class ViewportTurboRendererExtension extends GfxExtension {
} }
async refresh() { async refresh() {
if (this.state === 'inactive') return; if (this.state$.value === 'inactive') return;
this.clearCanvas(); this.clearCanvas();
// -> pending
// Determine the next state based on current conditions
let nextState: RenderingState;
if (this.viewport.zoom > this.options.zoomThreshold) { if (this.viewport.zoom > this.options.zoomThreshold) {
this.debugLog('Zoom above threshold, falling back to DOM rendering'); this.debugLog('Zoom above threshold, falling back to DOM rendering');
this.setState('pending'); nextState = 'pending';
this.clearOptimizedBlocks(); this.clearOptimizedBlocks();
} } else if (this.isZooming()) {
// -> zooming
else if (this.isZooming()) {
this.debugLog('Currently zooming, using placeholder rendering'); this.debugLog('Currently zooming, using placeholder rendering');
this.setState('zooming'); nextState = 'zooming';
this.paintPlaceholder(); this.paintPlaceholder();
this.updateOptimizedBlocks(); this.updateOptimizedBlocks();
} } else if (this.canUseBitmapCache()) {
// -> ready
else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap'); this.debugLog('Using cached bitmap');
this.setState('ready'); nextState = 'ready';
this.drawCachedBitmap(); this.drawCachedBitmap();
this.updateOptimizedBlocks(); this.updateOptimizedBlocks();
} } else {
// -> rendering this.debugLog('Starting bitmap rendering');
else { nextState = 'rendering';
this.setState('rendering'); this.state$.next(nextState);
await this.paintLayout(); await this.paintLayout();
this.drawCachedBitmap(); this.drawCachedBitmap();
this.updateOptimizedBlocks(); this.updateOptimizedBlocks();
// After rendering completes, transition to ready state
nextState = 'ready';
} }
}
debouncedRefresh = debounce(() => { this.state$.next(nextState);
this.refresh().catch(console.error); }
}, this.options.debounceTime);
invalidate() { invalidate() {
this.layoutVersion++; this.layoutVersion++;
@@ -188,13 +219,13 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.clearBitmap(); this.clearBitmap();
this.clearCanvas(); this.clearCanvas();
this.clearOptimizedBlocks(); this.clearOptimizedBlocks();
this.setState('pending'); this.state$.next('pending');
this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`); this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`);
} }
private debugLog(message: string) { private debugLog(message: string) {
if (!debug) return; if (!debug) return;
debugLog(message, this.state); debugLog(message, this.state$.value);
} }
private clearBitmap() { private clearBitmap() {
@@ -234,21 +265,21 @@ export class ViewportTurboRendererExtension extends GfxExtension {
); );
this.clearBitmap(); this.clearBitmap();
this.bitmap = e.data.bitmap; this.bitmap = e.data.bitmap;
this.setState('ready'); this.state$.next('ready');
resolve(); resolve();
} else { } else {
this.debugLog( this.debugLog(
`Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})` `Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})`
); );
e.data.bitmap.close(); e.data.bitmap.close();
this.setState('pending'); this.state$.next('pending');
resolve(); resolve();
} }
} else if (e.data.type === 'paintError') { } else if (e.data.type === 'paintError') {
this.debugLog( this.debugLog(
`Paint error: ${e.data.error} for blockType: ${e.data.blockType}` `Paint error: ${e.data.error} for blockType: ${e.data.blockType}`
); );
this.setState('pending'); this.state$.next('pending');
resolve(); resolve();
} }
}; };
@@ -275,7 +306,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private drawCachedBitmap() { private drawCachedBitmap() {
if (!this.bitmap) { if (!this.bitmap) {
this.debugLog('No cached bitmap available, requesting refresh'); this.debugLog('No cached bitmap available, requesting refresh');
this.debouncedRefresh(); this.refresh$.next();
return; return;
} }
@@ -301,17 +332,11 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.debugLog('Bitmap drawn to canvas'); 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 { private canOptimize(): boolean {
const isBelowZoomThreshold = const isBelowZoomThreshold =
this.viewport.zoom <= this.options.zoomThreshold; this.viewport.zoom <= this.options.zoomThreshold;
return ( return (
(this.state === 'ready' || this.state === 'zooming') && (this.state$.value === 'ready' || this.state$.value === 'zooming') &&
isBelowZoomThreshold isBelowZoomThreshold
); );
} }
@@ -335,7 +360,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.debugLog('Container resized, syncing canvas size'); this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host); syncCanvasSize(this.canvas, this.std.host);
this.invalidate(); this.invalidate();
this.debouncedRefresh(); this.refresh$.next();
} }
private paintPlaceholder() { private paintPlaceholder() {
@@ -5,9 +5,8 @@ import {
type IVec, type IVec,
Vec, Vec,
} from '@blocksuite/global/gfx'; } from '@blocksuite/global/gfx';
import { signal } from '@preact/signals-core';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash-es/debounce';
import { debounceTime, Subject } from 'rxjs'; import { BehaviorSubject, debounceTime, Subject } from 'rxjs';
import type { GfxViewportElement } from '.'; import type { GfxViewportElement } from '.';
@@ -100,19 +99,19 @@ export class Viewport {
center: IVec; center: IVec;
}>(); }>();
zooming$ = signal(false); zooming$ = new BehaviorSubject<boolean>(false);
panning$ = signal(false); panning$ = new BehaviorSubject<boolean>(false);
ZOOM_MAX = ZOOM_MAX; ZOOM_MAX = ZOOM_MAX;
ZOOM_MIN = ZOOM_MIN; ZOOM_MIN = ZOOM_MIN;
private readonly _resetZooming = debounce(() => { private readonly _resetZooming = debounce(() => {
this.zooming$.value = false; this.zooming$.next(false);
}, 200); }, 200);
private readonly _resetPanning = debounce(() => { private readonly _resetPanning = debounce(() => {
this.panning$.value = false; this.panning$.next(false);
}, 200); }, 200);
constructor() { constructor() {
@@ -296,9 +295,8 @@ export class Viewport {
this.viewportMoved.complete(); this.viewportMoved.complete();
this.viewportUpdated.complete(); this.viewportUpdated.complete();
this._resizeSubject.complete(); this._resizeSubject.complete();
this.zooming$.complete();
this.zooming$.value = false; this.panning$.complete();
this.panning$.value = false;
} }
getFitToScreenData( getFitToScreenData(
@@ -371,7 +369,7 @@ export class Viewport {
this._center.x = centerX; this._center.x = centerX;
this._center.y = centerY; this._center.y = centerY;
this.panning$.value = true; this.panning$.next(true);
this.viewportUpdated.next({ this.viewportUpdated.next({
zoom: this.zoom, zoom: this.zoom,
center: Vec.toVec(this.center) as IVec, center: Vec.toVec(this.center) as IVec,
@@ -530,7 +528,7 @@ export class Viewport {
Vec.mul(offset, prevZoom / newZoom) Vec.mul(offset, prevZoom / newZoom)
); );
if (wheel) { if (wheel) {
this.zooming$.value = true; this.zooming$.next(true);
} }
this.setCenter(newCenter[0], newCenter[1], forceUpdate); this.setCenter(newCenter[0], newCenter[1], forceUpdate);
this.viewportUpdated.next({ this.viewportUpdated.next({
-2
View File
@@ -3180,8 +3180,6 @@ __metadata:
"@blocksuite/block-std": "workspace:*" "@blocksuite/block-std": "workspace:*"
"@blocksuite/global": "workspace:*" "@blocksuite/global": "workspace:*"
"@blocksuite/store": "workspace:*" "@blocksuite/store": "workspace:*"
"@types/lodash-es": "npm:^4.17.12"
lodash-es: "npm:^4.17.21"
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
languageName: unknown languageName: unknown
linkType: soft linkType: soft