From bb1bbccd0f2980dca665b4561e6b45cc6e9fe3cc Mon Sep 17 00:00:00 2001 From: doodlewind <7312949+doodlewind@users.noreply.github.com> Date: Thu, 27 Mar 2025 04:50:32 +0000 Subject: [PATCH] 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. --- .../affine/gfx/turbo-renderer/package.json | 2 - .../gfx/turbo-renderer/src/turbo-renderer.ts | 133 +++++++++++------- .../framework/block-std/src/gfx/viewport.ts | 20 ++- yarn.lock | 2 - 4 files changed, 88 insertions(+), 69 deletions(-) diff --git a/blocksuite/affine/gfx/turbo-renderer/package.json b/blocksuite/affine/gfx/turbo-renderer/package.json index a007486caf..2bcea62966 100644 --- a/blocksuite/affine/gfx/turbo-renderer/package.json +++ b/blocksuite/affine/gfx/turbo-renderer/package.json @@ -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": { diff --git a/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts b/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts index 82f5f449c8..07fe4f7348 100644 --- a/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts +++ b/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts @@ -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('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(); 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() { diff --git a/blocksuite/framework/block-std/src/gfx/viewport.ts b/blocksuite/framework/block-std/src/gfx/viewport.ts index 73c2ef41df..ae6ab61cc1 100644 --- a/blocksuite/framework/block-std/src/gfx/viewport.ts +++ b/blocksuite/framework/block-std/src/gfx/viewport.ts @@ -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(false); + panning$ = new BehaviorSubject(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({ diff --git a/yarn.lock b/yarn.lock index 6dfc6485fa..94c5fe4ad7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3180,8 +3180,6 @@ __metadata: "@blocksuite/block-std": "workspace:*" "@blocksuite/global": "workspace:*" "@blocksuite/store": "workspace:*" - "@types/lodash-es": "npm:^4.17.12" - lodash-es: "npm:^4.17.21" rxjs: "npm:^7.8.1" languageName: unknown linkType: soft