diff --git a/bun.lock b/bun.lock index 98e47d8f..7405486f 100644 --- a/bun.lock +++ b/bun.lock @@ -227,6 +227,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", "globals": "^16.3.0", + "happy-dom": "^18.0.1", "nodemon": "^3.1.7", "tailwindcss": "^4.0.0", "tsx": "^4.20.6", @@ -1451,6 +1452,8 @@ "@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], @@ -2139,6 +2142,8 @@ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + "happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -3351,7 +3356,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -3589,6 +3594,8 @@ "cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -3629,6 +3636,8 @@ "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "happy-dom/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -3815,6 +3824,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 14eaf104..4d52e7cb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -103,6 +103,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", "globals": "^16.3.0", + "happy-dom": "^18.0.1", "nodemon": "^3.1.7", "tailwindcss": "^4.0.0", "tsx": "^4.20.6", diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx new file mode 100644 index 00000000..f443f91c --- /dev/null +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx @@ -0,0 +1,376 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +type OperationCounts = { + innerHTMLWrites: number; + querySelectorAllCalls: number; + appendCalls: number; + replaceCalls: number; + removeCalls: number; + getBoundingClientRectCalls: number; + viewBoxWrites: number; + resizeObserverCreates: number; + resizeObserverObserveCalls: number; + geometrySequence: Array<'read' | 'write'>; +}; + +type FixtureMetrics = OperationCounts & { + renderers: number; + markdownBlocks: number; + mermaidBlocks: number; + mermaidRenderedCount: number; + mermaidSvgCount: number; +}; + +const fixture = [ + '# Synthetic mount fixture', + '', + 'A paragraph with **bold text**, a table, and a stable link.', + '', + '| name | value |', + '| --- | ---: |', + '| alpha | 1 |', + '| beta | 2 |', + '', + '```typescript', + 'const answer = 42;', + 'console.log(answer);', + '```', + '', + '```mermaid', + 'graph TD', + ' A[Start] --> B[Finish]', + '```', + '', + '```mermaid', + 'graph LR', + ' Client[Client] --> Server[Server]', + '```', +].join('\n'); + +const fixtureWorkload = { + rendererCount: 3, + domBlocksPerRenderer: 1, + mermaidBlocksPerRenderer: 2, +}; + +let windowInstance: Window; +let previousGlobals: Map; +let activeCounts: OperationCounts | null = null; +let animationFrameQueue: FrameRequestCallback[] = []; +let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null; +let MarkdownRenderer: React.ComponentType<{ + content: string; + messageId: string; + isAnimated?: boolean; + enableFileReferences?: boolean; +}>; + +const makeCounts = (): OperationCounts => ({ + innerHTMLWrites: 0, + querySelectorAllCalls: 0, + appendCalls: 0, + replaceCalls: 0, + removeCalls: 0, + getBoundingClientRectCalls: 0, + viewBoxWrites: 0, + resizeObserverCreates: 0, + resizeObserverObserveCalls: 0, + geometrySequence: [], +}); + +const installGlobal = (name: string, value: Window[keyof Window]): void => { + previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); +}; + +const waitForSettledEffects = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await Promise.resolve(); +}; + +const flushAnimationFrame = async (): Promise => { + const callbacks = animationFrameQueue; + animationFrameQueue = []; + await act(async () => { + for (const callback of callbacks) callback(windowInstance.performance.now()); + await Promise.resolve(); + }); +}; + +const flushDeferredMermaidInitialization = async (): Promise => { + await flushAnimationFrame(); + await flushAnimationFrame(); +}; + +const mountFixture = async (rendererCount: number): Promise<{ + root: Root; + host: HTMLDivElement; + operations: OperationCounts; + counts: FixtureMetrics; +}> => { + const counts = makeCounts(); + activeCounts = counts; + const host = document.createElement('div'); + document.body.replaceChildren(host); + const root = createRoot(host); + await act(async () => { + root.render( + <> + {Array.from({ length: rendererCount }, (_, index) => ( + + ))} + , + ); + await waitForSettledEffects(); + }); + await act(async () => waitForSettledEffects()); + + const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length; + const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length; + const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length; + return { + root, + host, + operations: counts, + counts: { + ...counts, + renderers: rendererCount, + markdownBlocks: host.querySelectorAll('[data-md-block]').length, + mermaidBlocks, + mermaidRenderedCount, + mermaidSvgCount, + }, + }; +}; + +const runFixture = async (rendererCount: number): Promise => { + const { root, host, operations } = await mountFixture(rendererCount); + await flushDeferredMermaidInitialization(); + const counts: FixtureMetrics = { + ...operations, + renderers: rendererCount, + markdownBlocks: host.querySelectorAll('[data-md-block]').length, + mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length, + mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length, + mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length, + }; + await act(async () => root.unmount()); + return counts; +}; + +const initializePerformanceDom = async (): Promise => { + windowInstance = new Window({ url: 'http://localhost/' }); + windowInstance.document.write(''); + windowInstance.document.close(); + previousGlobals = new Map(); + installGlobal('window', windowInstance); + installGlobal('document', windowInstance.document); + installGlobal('navigator', windowInstance.navigator); + installGlobal('customElements', windowInstance.customElements); + for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) { + // SAFETY: these names are the DOM constructors installed by this happy-dom Window. + const globalValue = windowInstance[name as keyof Window]; + if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`); + installGlobal(name, globalValue); + } + Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) }); + Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => { + animationFrameQueue.push(callback); + return animationFrameQueue.length; + } }); + Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined }); + installGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const elementPrototype = Element.prototype; + const nodePrototype = Node.prototype; + const documentPrototype = Document.prototype; + const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'); + if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable'); + Object.defineProperty(Element.prototype, 'innerHTML', { + configurable: true, + get: innerHTMLDescriptor.get, + set(value: string) { + if (activeCounts) activeCounts.innerHTMLWrites += 1; + innerHTMLDescriptor.set?.call(this, value); + }, + }); + const originalQuerySelectorAll = elementPrototype.querySelectorAll; + Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf { + if (activeCounts) activeCounts.querySelectorAllCalls += 1; + return originalQuerySelectorAll.call(this, selectors); + } }); + const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll; + Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf { + if (activeCounts) activeCounts.querySelectorAllCalls += 1; + return originalDocumentQuerySelectorAll.call(this, selectors); + } }); + const originalAppendChild = nodePrototype.appendChild; + Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node { + if (activeCounts) activeCounts.appendCalls += 1; + return originalAppendChild.call(this, node); + } }); + const originalReplaceWith = elementPrototype.replaceWith; + Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void { + if (activeCounts) activeCounts.replaceCalls += 1; + return originalReplaceWith.apply(this, nodes); + } }); + const originalRemove = elementPrototype.remove; + Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void { + if (activeCounts) activeCounts.removeCalls += 1; + return originalRemove.call(this); + } }); + const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect; + Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect { + if (activeCounts) { + activeCounts.getBoundingClientRectCalls += 1; + activeCounts.geometrySequence.push('read'); + } + return originalGetBoundingClientRect.call(this); + } }); + const svgSetAttribute = SVGElement.prototype.setAttribute; + Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void { + if (name === 'viewBox' && activeCounts) { + activeCounts.viewBoxWrites += 1; + activeCounts.geometrySequence.push('write'); + } + return svgSetAttribute.call(this, name, value); + } }); + class CountingResizeObserver { + constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) { + if (activeCounts) activeCounts.resizeObserverCreates += 1; + notifyResize = callback; + } + + observe(): void { + if (activeCounts) activeCounts.resizeObserverObserveCalls += 1; + } + + unobserve(): void {} + + disconnect(): void {} + } + installGlobal('ResizeObserver', CountingResizeObserver); + + const fakeState = { + openContextPreview: () => undefined, + codeBlockLineWrap: false, + mermaidRenderingMode: 'svg', + }; + type UIStateSelection = typeof fakeState[keyof typeof fakeState]; + const { mock } = await import('bun:test'); + mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') })); + mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); + mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null })); + mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) })); + mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null })); + mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) })); + mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) })); + mock.module('@/lib/url', () => ({ isExternalHttpUrl: () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false })); + mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false })); + mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false })); + mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined })); + mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') })); + mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined })); + mock.module('beautiful-mermaid', () => ({ + renderMermaidASCII: () => 'diagram', + renderMermaidSVG: () => '', + })); + mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined })); + mock.module('./markdown/markdown-worker', () => ({ + highlightCodeInWorker: async () => null, + highlightLinesInWorker: async () => null, + highlightTokensInWorker: async () => null, + })); + mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children })); + const imported = await import('./MarkdownRendererImpl'); + MarkdownRenderer = imported.MarkdownRenderer; +}; + +await initializePerformanceDom(); + +afterAll(() => { + for (const [name, descriptor] of previousGlobals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } +}); + +describe('MarkdownRenderer DOM mount performance contract', () => { + test('defers and batches Mermaid controller initialization after Markdown mount', async () => { + const mounted = await mountFixture(fixtureWorkload.rendererCount); + const critical = mounted.counts; + + expect(critical.getBoundingClientRectCalls).toBe(0); + expect(critical.viewBoxWrites).toBe(0); + expect(critical.resizeObserverCreates).toBe(0); + expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6); + + await flushDeferredMermaidInitialization(); + const metrics = { + ...mounted.operations, + renderers: fixtureWorkload.rendererCount, + markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length, + mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length, + mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length, + mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length, + }; + + expect(metrics.renderers).toBe(3); + expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer); + expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer); + expect(metrics.mermaidRenderedCount).toBeGreaterThan(0); + expect(metrics.innerHTMLWrites).toBeGreaterThan(0); + expect(metrics.querySelectorAllCalls).toBeGreaterThan(0); + expect(metrics.appendCalls).toBeGreaterThan(0); + expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount); + expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount); + expect(metrics.resizeObserverCreates).toBe(1); + expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount); + expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write')); + + const viewport = mounted.host.querySelector('[data-markdown="mermaid-viewport"]'); + if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer'); + const readsBeforeResize = mounted.operations.getBoundingClientRectCalls; + const writesBeforeResize = mounted.operations.viewBoxWrites; + notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]); + expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize); + expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1); + console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics })); + await act(async () => mounted.root.unmount()); + }); + + test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => { + const mounted = await mountFixture(1); + await act(async () => mounted.root.unmount()); + await flushDeferredMermaidInitialization(); + + expect(mounted.operations.getBoundingClientRectCalls).toBe(0); + expect(mounted.operations.viewBoxWrites).toBe(0); + expect(mounted.operations.resizeObserverCreates).toBe(0); + }); + + test('keeps DOM operation fanout linear when renderer count doubles', async () => { + const three = await runFixture(3); + const six = await runFixture(6); + + expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2); + expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2); + expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6); + expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12); + expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12); + expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2); + expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2); + expect(three.resizeObserverCreates).toBe(1); + expect(six.resizeObserverCreates).toBe(1); + expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/mermaidViewer.ts b/packages/ui/src/components/chat/markdown/mermaidViewer.ts index d4532546..2dd89307 100644 --- a/packages/ui/src/components/chat/markdown/mermaidViewer.ts +++ b/packages/ui/src/components/chat/markdown/mermaidViewer.ts @@ -22,6 +22,18 @@ type MermaidViewerController = { cleanup: () => void; }; +type InternalMermaidViewerController = MermaidViewerController & { + viewport: HTMLElement; + fitToViewport: (viewport: MermaidViewport) => void; +}; + +type MermaidViewerRegistryState = { + container: HTMLElement; + controllers: Map; + signatures: Map; + disposed: boolean; +}; + type MermaidSvgBoundsSource = { viewBox?: string | null; width?: string | number | null; @@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & { const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0; const parseSvgNumber = (value: string | number | null | undefined): number | null => { - if (typeof value === 'number') { - return isPositiveFinite(value) ? value : null; - } - if (typeof value !== 'string') { - return null; - } - const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/); + if (value === null || value === undefined) return null; + const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/); if (!match) { return null; } @@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({ }; const controllerByBlock = new WeakMap(); - -export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => ( - block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null -); +const controllerByViewport = new WeakMap(); +const activeControllers = new Set(); +const pendingRegistries = new Set(); +// Controllers are non-essential for the static SVG. Initialize all renderers +// from one post-presentation batch so geometry reads precede every SVG write. +let sharedResizeObserver: ResizeObserver | null = null; +let pendingRegistryFlushFrame: number | null = null; +let pendingResizeFrame: number | null = null; const getSvgViewport = (block: HTMLElement): HTMLElement | null => ( block.querySelector('[data-markdown="mermaid-viewport"]') @@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => ( target instanceof Element && Boolean(target.closest('button, a, [role="button"]')) ); -const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => { +const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => { + const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport)); + controllers.forEach((controller, index) => { + const viewport = viewportSizes[index]; + if (viewport) controller.fitToViewport(viewport); + }); +}; + +const scheduleActiveControllerFit = (): void => { + if (pendingResizeFrame !== null || activeControllers.size === 0) return; + pendingResizeFrame = window.requestAnimationFrame(() => { + pendingResizeFrame = null; + fitControllers(Array.from(activeControllers)); + }); +}; + +const ensureSharedResizeObserver = (): ResizeObserver | null => { + if (sharedResizeObserver) return sharedResizeObserver; + const ResizeObserverConstructor = globalThis.ResizeObserver; + if (!ResizeObserverConstructor) return null; + sharedResizeObserver = new ResizeObserverConstructor((entries) => { + for (const entry of entries) { + if (!(entry.target instanceof HTMLElement)) continue; + controllerByViewport.get(entry.target)?.fitToViewport({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + return sharedResizeObserver; +}; + +const registerController = (controller: InternalMermaidViewerController): void => { + if (activeControllers.has(controller)) return; + const wasEmpty = activeControllers.size === 0; + activeControllers.add(controller); + controllerByViewport.set(controller.viewport, controller); + ensureSharedResizeObserver()?.observe(controller.viewport); + if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit); +}; + +const unregisterController = (controller: InternalMermaidViewerController): void => { + if (!activeControllers.delete(controller)) return; + sharedResizeObserver?.unobserve(controller.viewport); + controllerByViewport.delete(controller.viewport); + if (activeControllers.size > 0) return; + sharedResizeObserver?.disconnect(); + sharedResizeObserver = null; + window.removeEventListener('resize', scheduleActiveControllerFit); + if (pendingResizeFrame !== null) { + window.cancelAnimationFrame(pendingResizeFrame); + pendingResizeFrame = null; + } +}; + +const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => { const viewport = getSvgViewport(block); const svg = block.querySelector('[data-markdown="mermaid"] svg'); if (!viewport || !svg) { @@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl svg.removeAttribute('height'); }; + const fitToViewport = (size: MermaidViewport): void => { + applyViewBox(fitMermaidViewBox(contentBox, size)); + }; + const fit = (): void => { - applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport))); + fitToViewport(getViewportSize(viewport)); }; const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => { @@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl } }; - const onResize = (): void => { - fit(); - }; - viewport.addEventListener('wheel', onWheel, { passive: false }); viewport.addEventListener('pointerdown', onPointerDown); viewport.addEventListener('pointermove', onPointerMove); viewport.addEventListener('pointerup', stopPan); viewport.addEventListener('pointercancel', stopPan); - window.addEventListener('resize', onResize); - const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize); - observer?.observe(viewport); - fit(); - - return { + const controller: InternalMermaidViewerController = { + viewport, zoomIn, zoomOut, fit, + fitToViewport, cleanup: () => { + unregisterController(controller); viewport.removeEventListener('wheel', onWheel); viewport.removeEventListener('pointerdown', onPointerDown); viewport.removeEventListener('pointermove', onPointerMove); viewport.removeEventListener('pointerup', stopPan); viewport.removeEventListener('pointercancel', stopPan); - window.removeEventListener('resize', onResize); - observer?.disconnect(); if (clearClickSuppressionTimer !== null) { window.clearTimeout(clearClickSuppressionTimer); } @@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl controllerByBlock.delete(block); }, }; + return controller; }; -export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => { - const controllers = new Map(); - const signatures = new Map(); - - const refresh = (): void => { - for (const [block, controller] of Array.from(controllers.entries())) { - const signature = getBlockViewerSignature(block); - if (!container.contains(block) || signature !== signatures.get(block)) { - controller.cleanup(); - controllers.delete(block); - signatures.delete(block); - } +const removeStaleControllers = (state: MermaidViewerRegistryState): void => { + for (const [block, controller] of state.controllers) { + const signature = getBlockViewerSignature(block); + if (!state.container.contains(block) || signature !== state.signatures.get(block)) { + controller.cleanup(); + state.controllers.delete(block); + state.signatures.delete(block); } + } +}; - for (const block of Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR))) { - if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) { - continue; - } - const controller = createMermaidViewerController(block); - if (!controller) { - continue; - } - controllers.set(block, controller); - signatures.set(block, getBlockViewerSignature(block)); - controllerByBlock.set(block, controller); - } +const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => { + if (state.disposed) return []; + const newControllers: InternalMermaidViewerController[] = []; + for (const block of Array.from(state.container.querySelectorAll(MERMAID_BLOCK_SELECTOR))) { + if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue; + const controller = createMermaidViewerController(block); + if (!controller) continue; + state.controllers.set(block, controller); + state.signatures.set(block, getBlockViewerSignature(block)); + controllerByBlock.set(block, controller); + newControllers.push(controller); + } + return newControllers; +}; + +const flushPendingRegistries = (): void => { + const registries = Array.from(pendingRegistries); + pendingRegistries.clear(); + const newControllers: InternalMermaidViewerController[] = []; + for (const state of registries) { + if (state.disposed) continue; + removeStaleControllers(state); + newControllers.push(...collectNewControllers(state)); + } + fitControllers(newControllers); + for (const controller of newControllers) registerController(controller); +}; + +const schedulePendingRegistryFlush = (): void => { + if (pendingRegistryFlushFrame !== null) return; + pendingRegistryFlushFrame = window.requestAnimationFrame(() => { + pendingRegistryFlushFrame = null; + pendingRegistryFlushFrame = window.requestAnimationFrame(() => { + pendingRegistryFlushFrame = null; + flushPendingRegistries(); + }); + }); +}; + +const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => { + if (state.disposed) return; + removeStaleControllers(state); + pendingRegistries.add(state); + schedulePendingRegistryFlush(); +}; + +export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => { + if (!(block instanceof HTMLElement)) return null; + const existing = controllerByBlock.get(block); + if (existing) return existing; + + for (const state of pendingRegistries) { + if (!state.container.contains(block)) continue; + flushPendingRegistries(); + return controllerByBlock.get(block) ?? null; + } + return null; +}; + +export const createMermaidViewerRegistry = (container: HTMLElement) => { + const state: MermaidViewerRegistryState = { + container, + controllers: new Map(), + signatures: new Map(), + disposed: false, }; + const refresh = (): void => scheduleRegistryRefresh(state); + const cleanup = (): void => { - for (const controller of controllers.values()) { + state.disposed = true; + pendingRegistries.delete(state); + for (const controller of state.controllers.values()) { controller.cleanup(); } - controllers.clear(); - signatures.clear(); + state.controllers.clear(); + state.signatures.clear(); }; refresh();