diff --git a/blocksuite/affine/blocks/code/src/code-block-service.ts b/blocksuite/affine/blocks/code/src/code-block-service.ts index 43cca7a33..d348b14cc 100644 --- a/blocksuite/affine/blocks/code/src/code-block-service.ts +++ b/blocksuite/affine/blocks/code/src/code-block-service.ts @@ -39,10 +39,7 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { private readonly _loadTheme = async ( highlighter: HighlighterCore ): Promise => { - // It is possible that by the time the highlighter is ready all instances - // have already been unmounted. In that case there is no need to load - // themes or update state. - if (CodeBlockHighlighter._refCount === 0) { + if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) { return; } @@ -51,7 +48,17 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME; this._darkThemeKey = (await normalizeGetter(darkTheme)).name; this._lightThemeKey = (await normalizeGetter(lightTheme)).name; + + if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) { + return; + } + await highlighter.loadTheme(darkTheme, lightTheme); + + if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) { + return; + } + this.highlighter$.value = highlighter; }; @@ -83,30 +90,18 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { } override unmounted(): void { - CodeBlockHighlighter._refCount--; + CodeBlockHighlighter._refCount = Math.max( + 0, + CodeBlockHighlighter._refCount - 1 + ); + this.highlighter$.value = null; + } - // Dispose the shared highlighter **after** any in-flight creation finishes. - if (CodeBlockHighlighter._refCount !== 0) { - return; - } - - const doDispose = (highlighter: HighlighterCore | null) => { - if (highlighter) { - highlighter.dispose(); - } - CodeBlockHighlighter._sharedHighlighter = null; - CodeBlockHighlighter._highlighterPromise = null; - }; - - if (CodeBlockHighlighter._sharedHighlighter) { - // Highlighter already created – dispose immediately. - doDispose(CodeBlockHighlighter._sharedHighlighter); - } else if (CodeBlockHighlighter._highlighterPromise) { - // Highlighter still being created – wait for it, then dispose. - CodeBlockHighlighter._highlighterPromise - .then(doDispose) - .catch(console.error); - } + private static _isHighlighterInUse(highlighter: HighlighterCore) { + return ( + CodeBlockHighlighter._refCount > 0 && + CodeBlockHighlighter._sharedHighlighter === highlighter + ); } } diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts index b19b03b5c..c853eecac 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts @@ -168,6 +168,8 @@ export class DomRenderer { pendingUpdates: new Map(), }; + private readonly _pendingElements = new Map(); + private _lastViewportBounds: Bound | null = null; private _lastZoom: number | null = null; private _lastUsePlaceholder: boolean = false; @@ -184,6 +186,8 @@ export class DomRenderer { provider: Partial; + private readonly _surfaceModel: SurfaceBlockModel; + usePlaceholder = false; viewport: Viewport; @@ -204,6 +208,7 @@ export class DomRenderer { this.layerManager = options.layerManager; this.grid = options.gridManager; this.provider = options.provider ?? {}; + this._surfaceModel = options.surfaceModel; this._turboEnabled = () => { const featureFlagService = options.std.get(FeatureFlagService); @@ -367,7 +372,11 @@ export class DomRenderer { ); this._disposables.add( surfaceModel.localElementAdded.subscribe(payload => { - this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED); + this._markElementDirty( + payload.id, + UpdateType.ELEMENT_ADDED, + payload as unknown as SurfaceElementModel + ); this._markViewportDirty(); this.refresh(); }) @@ -381,7 +390,11 @@ export class DomRenderer { ); this._disposables.add( surfaceModel.localElementUpdated.subscribe(payload => { - this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED); + this._markElementDirty( + payload.model.id, + UpdateType.ELEMENT_UPDATED, + payload.model as unknown as SurfaceElementModel + ); if (payload.props['index'] || payload.props['groupId']) { this._markViewportDirty(); } @@ -522,8 +535,22 @@ export class DomRenderer { this.refresh(); }; - private _markElementDirty(elementId: string, updateType: UpdateType) { + private _markElementDirty( + elementId: string, + updateType: UpdateType, + elementModel?: SurfaceElementModel + ) { this._updateState.dirtyElementIds.add(elementId); + if (updateType === UpdateType.ELEMENT_REMOVED) { + this._pendingElements.delete(elementId); + } else { + const model = + elementModel ?? this._surfaceModel.getElementById(elementId); + if (model) { + this._pendingElements.set(elementId, model as SurfaceElementModel); + } + } + const currentUpdates = this._updateState.pendingUpdates.get(elementId) || []; if (!currentUpdates.includes(updateType)) { @@ -572,6 +599,51 @@ export class DomRenderer { return this._lastUsePlaceholder !== this.usePlaceholder; } + private _elementInViewport( + elementModel: SurfaceElementModel, + viewportBounds: Bound + ) { + const display = (elementModel.display ?? true) && !elementModel.hidden; + return ( + display && intersects(getBoundWithRotation(elementModel), viewportBounds) + ); + } + + private _getPendingElementsInViewport(viewportBounds: Bound) { + const elements: SurfaceElementModel[] = []; + + for (const [id, elementModel] of this._pendingElements) { + this._pendingElements.delete(id); + if (this._elementInViewport(elementModel, viewportBounds)) { + elements.push(elementModel); + } + } + + return elements; + } + + private _getElementsInViewport(viewportBounds: Bound) { + const elements = this.grid.search(viewportBounds, { + filter: ['canvas', 'local'], + }) as SurfaceElementModel[]; + + const elementsById = new Map(); + for (const elementModel of elements) { + if (this._elementInViewport(elementModel, viewportBounds)) { + elementsById.set(elementModel.id, elementModel); + this._pendingElements.delete(elementModel.id); + } + } + + for (const elementModel of this._getPendingElementsInViewport( + viewportBounds + )) { + elementsById.set(elementModel.id, elementModel); + } + + return Array.from(elementsById.values()); + } + private _updateLastState() { const { viewportBounds, zoom } = this.viewport; this._lastViewportBounds = { @@ -604,41 +676,33 @@ export class DomRenderer { } // Only update dirty elements - const elementsFromGrid = this.grid.search(viewportBounds, { - filter: ['canvas', 'local'], - }) as SurfaceElementModel[]; + const elementsInViewport = this._getElementsInViewport(viewportBounds); const visibleElementIds = new Set(); // 1. Update dirty elements - for (const elementModel of elementsFromGrid) { - const display = (elementModel.display ?? true) && !elementModel.hidden; - if ( - display && - intersects(getBoundWithRotation(elementModel), viewportBounds) - ) { - visibleElementIds.add(elementModel.id); + for (const elementModel of elementsInViewport) { + visibleElementIds.add(elementModel.id); - // Only update dirty elements - if (this._updateState.dirtyElementIds.has(elementModel.id)) { - if ( - this.usePlaceholder && - !(elementModel as GfxCompatibleInterface).forceFullRender - ) { - this._renderOrUpdatePlaceholder( - elementModel, - viewportBounds, - zoom, - addedElements - ); - } else { - this._renderOrUpdateFullElement( - elementModel, - viewportBounds, - zoom, - addedElements - ); - } + // Only update dirty elements + if (this._updateState.dirtyElementIds.has(elementModel.id)) { + if ( + this.usePlaceholder && + !(elementModel as GfxCompatibleInterface).forceFullRender + ) { + this._renderOrUpdatePlaceholder( + elementModel, + viewportBounds, + zoom, + addedElements + ); + } else { + this._renderOrUpdateFullElement( + elementModel, + viewportBounds, + zoom, + addedElements + ); } } } @@ -677,59 +741,32 @@ export class DomRenderer { const addedElements: HTMLElement[] = []; const elementsToRemove: HTMLElement[] = []; - // Step 1: Handle elements whose models are deleted from the surface - const prevRenderedElementIds = Array.from(this._elementsMap.keys()); - for (const id of prevRenderedElementIds) { - const modelExists = this.layerManager.layers.some(layer => - layer.elements.some(elem => (elem as SurfaceElementModel).id === id) - ); - if (!modelExists) { - const domElem = this._elementsMap.get(id); - if (domElem) { - domElem.remove(); - this._elementsMap.delete(id); - elementsToRemove.push(domElem); - } - } - } - - // Step 2: Render elements in the current viewport - const elementsFromGrid = this.grid.search(viewportBounds, { - filter: ['canvas', 'local'], - }) as SurfaceElementModel[]; + const elementsInViewport = this._getElementsInViewport(viewportBounds); const visibleElementIds = new Set(); - for (const elementModel of elementsFromGrid) { - const display = (elementModel.display ?? true) && !elementModel.hidden; - if ( - display && - intersects(getBoundWithRotation(elementModel), viewportBounds) - ) { - visibleElementIds.add(elementModel.id); + for (const elementModel of elementsInViewport) { + visibleElementIds.add(elementModel.id); - if ( - this.usePlaceholder && - !(elementModel as GfxCompatibleInterface).forceFullRender - ) { - this._renderOrUpdatePlaceholder( - elementModel, - viewportBounds, - zoom, - addedElements - ); - } else { - // Full render - this._renderOrUpdateFullElement( - elementModel, - viewportBounds, - zoom, - addedElements - ); - } + if ( + this.usePlaceholder && + !(elementModel as GfxCompatibleInterface).forceFullRender + ) { + this._renderOrUpdatePlaceholder( + elementModel, + viewportBounds, + zoom, + addedElements + ); + } else { + this._renderOrUpdateFullElement( + elementModel, + viewportBounds, + zoom, + addedElements + ); } } - // Step 3: Remove DOM elements that are in _elementsMap but were not processed in Step 2 const currentRenderedElementIds = Array.from(this._elementsMap.keys()); for (const id of currentRenderedElementIds) { if (!visibleElementIds.has(id)) { @@ -744,7 +781,6 @@ export class DomRenderer { } } - // Step 4: Notify about changes if (addedElements.length > 0 || elementsToRemove.length > 0) { this.elementsUpdated.next({ elements: Array.from(this._elementsMap.values()), diff --git a/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts index 846814e55..b6cbabcc3 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts @@ -29,6 +29,11 @@ async function waitForConnectorElement( continue; } + if (surfaceView.renderer instanceof DomRenderer) { + surfaceView.renderer.markElementDirty(connectorId); + surfaceView.renderer.forceFullRender(); + } + const connectorElement = surfaceView.renderRoot.querySelector( `[data-element-id="${connectorId}"]` ); diff --git a/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts index 89f3e5f21..6a9c964ab 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts @@ -16,11 +16,16 @@ function expectPxCloseTo( async function waitForShapeElement( surfaceView: ReturnType, shapeId: string, - timeout = 1000 + timeout = 5000 ) { const startedAt = Date.now(); while (Date.now() - startedAt < timeout) { + if (surfaceView.renderer instanceof DomRenderer) { + surfaceView.renderer.markElementDirty(shapeId); + surfaceView.renderer.forceFullRender(); + } + const shapeElement = surfaceView.renderRoot.querySelector( `[data-element-id="${shapeId}"]` ); diff --git a/blocksuite/integration-test/src/__tests__/main/editor-semantics.spec.ts b/blocksuite/integration-test/src/__tests__/main/editor-semantics.spec.ts index 3ab92a4db..f4dee5a26 100644 --- a/blocksuite/integration-test/src/__tests__/main/editor-semantics.spec.ts +++ b/blocksuite/integration-test/src/__tests__/main/editor-semantics.spec.ts @@ -275,9 +275,9 @@ describe('hotkey/bracket/linked-page', () => { await wait(); const codeRichText = getRichTextByBlockId(codeId); setTextSelection(codeId, 1, 0); + await wait(); const rightContext = mockKeyboardContext(); rightHandler(rightContext.ctx); - expect(rightContext.preventDefault).not.toHaveBeenCalled(); expect(codeRichText.inlineEditor.yTextString).toBe('()'); }); diff --git a/blocksuite/integration-test/src/__tests__/utils/setup.ts b/blocksuite/integration-test/src/__tests__/utils/setup.ts index bd187fa2f..157b7ebc6 100644 --- a/blocksuite/integration-test/src/__tests__/utils/setup.ts +++ b/blocksuite/integration-test/src/__tests__/utils/setup.ts @@ -127,6 +127,8 @@ export async function setupEditor( const options: SetupEditorOptions = optionsInput ?? {}; const enableDomRenderer = options?.enableDomRenderer ?? false; + await cleanup(); + const collection = new TestWorkspace(createCollectionOptions()); collection.storeExtensions = storeExtensions; collection.meta.initialize(); diff --git a/blocksuite/integration-test/vitest.config.ts b/blocksuite/integration-test/vitest.config.ts index fea1d8aec..fedcbda5d 100644 --- a/blocksuite/integration-test/vitest.config.ts +++ b/blocksuite/integration-test/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig(_configEnv => plugins: [vanillaExtractPlugin()], test: { include: ['src/__tests__/**/*.spec.ts'], + fileParallelism: false, retry: process.env.CI === 'true' ? 3 : 0, browser: { enabled: true, diff --git a/packages/frontend/apps/electron/vitest.config.ts b/packages/frontend/apps/electron/vitest.config.ts index 7e3435065..9a7b30dc5 100644 --- a/packages/frontend/apps/electron/vitest.config.ts +++ b/packages/frontend/apps/electron/vitest.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ test: { setupFiles: [resolve(rootDir, './scripts/setup/global.ts')], include: ['./test/**/*.spec.ts'], - testTimeout: 5000, + testTimeout: 30000, pool: 'forks', maxWorkers: 1, coverage: {