('[data-markdown-content]') ?? container;
if (!target) return;
+ const decorationId = getMarkdownDecorationId(ctx);
if (text && target.childNodes.length === 0) {
- const block = document.createElement('div');
- block.setAttribute('data-md-block', '');
- // `display:contents` keeps margin-collapsing/spacing identical to a flat
- // HTML body — the wrapper exists only for per-block reconciliation.
- block.style.display = 'contents';
- block.innerHTML = renderMarkdownSync(text, imageMode);
- // Decorate synchronously too: wrap code blocks in their framed card,
- // mark inline code, build table controls, etc. The async pass re-decorates
- // its own DOM before morphing, so without this the first paint shows bare
- // /tables that "snap" into their decorated form a tick later. Matching
- // the structure here keeps the async morph to syntax colors only.
- decorateMarkdown(block, ctx);
- target.appendChild(block);
- if (shouldRefreshMermaidViewers(block)) {
- refreshMermaidViewers();
+ const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
+ if (cachedBlocks) {
+ let hasMermaidBlock = false;
+ for (const cachedBlock of cachedBlocks) {
+ const block = document.createElement('div');
+ block.setAttribute('data-md-block', '');
+ block.style.display = 'contents';
+ block.innerHTML = cachedBlock.html;
+ decorateMarkdown(block, ctx);
+ block.setAttribute('data-md-id', cachedBlock.id);
+ block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
+ target.appendChild(block);
+ }
+ if (hasMermaidBlock) refreshMermaidViewers();
+ } else {
+ const block = document.createElement('div');
+ block.setAttribute('data-md-block', '');
+ block.style.display = 'contents';
+ block.innerHTML = renderMarkdownSync(text, imageMode);
+ decorateMarkdown(block, ctx);
+ block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ target.appendChild(block);
+ if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
}
+ } else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
+ // StrictMode re-runs this setup after the cleanup probe. The DOM remains,
+ // but the viewer registry does not, so recreate it without reinstalling
+ // or re-decorating ordinary blocks.
+ refreshMermaidViewers();
}
- }, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
+ }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -810,14 +842,12 @@ const useMorphdomMarkdown = ({
if (!container) return;
const target = container.querySelector('[data-markdown-content]') ?? container;
let active = true;
+ const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
- // Reconcile per block: only re-morph blocks whose content changed, leaving
- // stable leading blocks untouched. Keeps per-stream-step DOM work bounded
- // to the trailing (growing) block instead of the whole message.
blocks.forEach((block, index) => {
let el = existing[index];
if (!el) {
@@ -826,7 +856,28 @@ const useMorphdomMarkdown = ({
el.style.display = 'contents';
target.appendChild(el);
}
- if (el.getAttribute('data-md-id') === block.id) return;
+ if (el.getAttribute('data-md-id') === block.id) {
+ if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
+ const hasMermaidBlock = shouldRefreshMermaidViewers(el);
+ if (hasMermaidBlock) {
+ mermaidViewerRef.current?.cleanup();
+ mermaidViewerRef.current = null;
+ }
+ const replacement = document.createElement('div');
+ replacement.setAttribute('data-md-block', '');
+ replacement.style.display = 'contents';
+ replacement.innerHTML = block.html;
+ decorateMarkdown(replacement, ctx);
+ replacement.setAttribute('data-md-id', block.id);
+ replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ el.replaceWith(replacement);
+ if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
+ }
+ if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
+ refreshMermaidViewers();
+ }
+ return;
+ }
const temp = document.createElement('div');
temp.innerHTML = block.html;
@@ -838,12 +889,12 @@ const useMorphdomMarkdown = ({
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
});
el.setAttribute('data-md-id', block.id);
+ el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
});
- // Remove any trailing block elements no longer present.
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
let removedMermaidBlock = false;
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -856,7 +907,6 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
-
});
return () => {
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
index 9153250d..e1321496 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
@@ -49,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
+ getCachedMarkdownBlocks,
+ renderMarkdownBlocks,
renderMarkdownSync,
+ resetMarkdownHtmlCacheForTests,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
@@ -90,6 +93,47 @@ describe('markdown sanitization', () => {
});
+describe('Markdown block cache reads', () => {
+ test('returns all settled blocks synchronously after a full cache hit', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const text = '**cached** settled markdown';
+
+ expect(getCachedMarkdownBlocks(text)).toBeNull();
+ const rendered = await renderMarkdownBlocks(text, false);
+
+ expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
+ });
+
+ test('returns null for a cold or partial settled miss', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const first = 'first settled block';
+ const changed = 'first settled block\n\nsecond settled block';
+
+ await renderMarkdownBlocks(first, false);
+
+ expect(getCachedMarkdownBlocks(changed)).toBeNull();
+ });
+
+ test('keeps image mode identity out of the settled full hit', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const text = '';
+
+ await renderMarkdownBlocks(text, false, 'inline');
+
+ expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
+ expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
+ });
+
+ test('does not treat streaming live-cache entries as settled full hits', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const text = 'streaming markdown';
+
+ await renderMarkdownBlocks(text, true);
+
+ expect(getCachedMarkdownBlocks(text)).toBeNull();
+ });
+});
+
describe('Markdown images', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index 822a3168..91943d74 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -548,10 +548,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size,
});
-const parseBlock = async (
- block: MarkdownBlock,
- imageMode: MarkdownImageMode,
-): Promise => {
+/**
+ * Read a settled render synchronously when every block is already in the full
+ * cache. Cache reads retain the existing LRU `get` semantics and do not insert
+ * or expand either cache.
+ */
+export const getCachedMarkdownBlocks = (
+ text: string,
+ imageMode: MarkdownImageMode = 'inline',
+): RenderedBlock[] | null => {
+ if (!text) return [];
+
+ const blocks = streamBlocks(text, false);
+ const rendered: RenderedBlock[] = [];
+ for (const block of blocks) {
+ const contentHash = contentFingerprint(block.raw);
+ const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
+ const html = fullBlockCache.get(id);
+ if (html === undefined) return null;
+ rendered.push({ id, html });
+ }
+ return rendered;
+};
+
+const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
From 1c3c26f2bfd5dabf659b1ed881c6103a825081a4 Mon Sep 17 00:00:00 2001
From: c_w_xiaohei <1641233466@qq.com>
Date: Fri, 21 Aug 2026 21:08:09 +0800
Subject: [PATCH 03/19] perf(ui): batch Mermaid viewer initialization
---
bun.lock | 13 +-
packages/ui/package.json | 1 +
.../MarkdownRendererImpl.performance.test.tsx | 376 ++++++++++++++++++
.../components/chat/markdown/mermaidViewer.ts | 221 +++++++---
4 files changed, 558 insertions(+), 53 deletions(-)
create mode 100644 packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
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();
From 1dfa3aee2fd112b540b51532e41e85f68592f22c Mon Sep 17 00:00:00 2001
From: c_w_xiaohei <1641233466@qq.com>
Date: Sat, 22 Aug 2026 03:14:15 +0800
Subject: [PATCH 04/19] perf(ui): stage history overscan restoration
---
.../MessageList.activationOverscan.test.tsx | 102 ++++++++++++++++++
.../ui/src/components/chat/MessageList.tsx | 4 +-
.../components/chat/useActivationOverscan.ts | 34 ++++++
3 files changed, 139 insertions(+), 1 deletion(-)
create mode 100644 packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx
create mode 100644 packages/ui/src/components/chat/useActivationOverscan.ts
diff --git a/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx b/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx
new file mode 100644
index 00000000..16b6d446
--- /dev/null
+++ b/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { Window } from 'happy-dom';
+import React from 'react';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+import { useActivationOverscan } from './useActivationOverscan';
+
+type Frame = FrameRequestCallback;
+
+describe('MessageList activation overscan', () => {
+ let windowInstance: Window;
+ let host: HTMLDivElement;
+ let root: Root;
+ let pendingFrames: Map;
+ let nextFrameId: number;
+ let renderCount: number;
+
+ beforeEach(() => {
+ windowInstance = new Window();
+ Object.assign(globalThis, {
+ window: windowInstance,
+ document: windowInstance.document,
+ HTMLElement: windowInstance.HTMLElement,
+ Element: windowInstance.Element,
+ Node: windowInstance.Node,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ pendingFrames = new Map();
+ nextFrameId = 1;
+ renderCount = 0;
+ Object.defineProperty(windowInstance, 'requestAnimationFrame', {
+ configurable: true,
+ value: (callback: Frame) => {
+ const frameId = nextFrameId;
+ nextFrameId += 1;
+ pendingFrames.set(frameId, callback);
+ return frameId;
+ },
+ });
+ Object.defineProperty(windowInstance, 'cancelAnimationFrame', {
+ configurable: true,
+ value: (id: number) => {
+ pendingFrames.delete(id);
+ },
+ });
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ root = createRoot(host);
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ windowInstance.close();
+ });
+
+ const Harness = ({ normalOverscan }: { normalOverscan: number }) => {
+ renderCount += 1;
+ const overscan = useActivationOverscan(true, normalOverscan);
+ return ;
+ };
+
+ const runNextFrame = async (timestamp: number): Promise => {
+ const nextFrame = pendingFrames.entries().next();
+ if (nextFrame.done) throw new Error('No animation frame is pending');
+
+ const [frameId, callback] = nextFrame.value;
+ pendingFrames.delete(frameId);
+ await act(async () => callback(timestamp));
+ };
+
+ test('restores normal overscan in at most two renders after the first paint opportunity', async () => {
+ await act(async () => root.render());
+ expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0');
+ expect(renderCount).toBe(1);
+
+ await runNextFrame(0);
+ expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0');
+ expect(renderCount).toBe(1);
+
+ await runNextFrame(16);
+ expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('4');
+ expect(renderCount).toBe(2);
+
+ await runNextFrame(32);
+ expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('8');
+ expect(renderCount).toBe(3);
+ });
+
+ test('cancels every pending restoration frame when the list unmounts', async () => {
+ for (const framesToRun of [0, 1, 2]) {
+ await act(async () => root.render());
+ for (let frame = 0; frame < framesToRun; frame += 1) {
+ await runNextFrame(frame * 16);
+ }
+
+ expect(pendingFrames.size).toBe(1);
+ await act(async () => root.render(null));
+ expect(pendingFrames.size).toBe(0);
+ }
+ });
+});
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx
index d573c1a9..74b4b6db 100644
--- a/packages/ui/src/components/chat/MessageList.tsx
+++ b/packages/ui/src/components/chat/MessageList.tsx
@@ -28,6 +28,7 @@ import {
getShellBridgeAssistantDetails,
type ShellBridgeDetails,
} from './lib/shellBridge';
+import { useActivationOverscan } from './useActivationOverscan';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
@@ -952,6 +953,7 @@ type StaticHistoryListProps = {
const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
const isTanstack = engine === 'tanstack';
+ const overscan = useActivationOverscan(isTanstack, resolveTanstackOverscan());
// --- Quiet-window prepend (mobile) --------------------------------------
// Gesture tracking for the deferred-prepend decision. Refs only: reading
@@ -1051,7 +1053,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef,
enabled: isTanstack,
getScrollElement: () => scrollRef?.current ?? null,
estimateSize: () => estimatedEntrySizeRef.current,
- overscan: resolveTanstackOverscan(),
+ overscan,
scrollToFn: (offset, options, instance) => {
// Expose the new total height before core writes an anchor
// correction so the browser does not clamp the offset to the old
diff --git a/packages/ui/src/components/chat/useActivationOverscan.ts b/packages/ui/src/components/chat/useActivationOverscan.ts
new file mode 100644
index 00000000..866b0504
--- /dev/null
+++ b/packages/ui/src/components/chat/useActivationOverscan.ts
@@ -0,0 +1,34 @@
+import * as React from 'react';
+
+export const useActivationOverscan = (enabled: boolean, normalOverscan: number): number => {
+ const [recoveryStep, setRecoveryStep] = React.useState(0);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ setRecoveryStep(0);
+ return;
+ }
+
+ let halfOverscanFrame: number | undefined;
+ const paintOpportunityFrame = window.requestAnimationFrame(() => {
+ halfOverscanFrame = window.requestAnimationFrame(() => {
+ React.startTransition(() => setRecoveryStep(1));
+ });
+ });
+ return () => {
+ window.cancelAnimationFrame(paintOpportunityFrame);
+ if (halfOverscanFrame !== undefined) window.cancelAnimationFrame(halfOverscanFrame);
+ };
+ }, [enabled]);
+
+ React.useEffect(() => {
+ if (!enabled || recoveryStep !== 1) return;
+ const normalOverscanFrame = window.requestAnimationFrame(() => {
+ React.startTransition(() => setRecoveryStep(2));
+ });
+ return () => window.cancelAnimationFrame(normalOverscanFrame);
+ }, [enabled, recoveryStep]);
+
+ if (!enabled || recoveryStep >= 2) return normalOverscan;
+ return recoveryStep === 0 ? 0 : Math.ceil(normalOverscan / 2);
+};
From 77da97953fe7a84f5f69341af5ef1705acd00b1b Mon Sep 17 00:00:00 2001
From: c_w_xiaohei <1641233466@qq.com>
Date: Sat, 22 Aug 2026 18:15:04 +0800
Subject: [PATCH 05/19] perf(ui): reuse detached markdown DOM
---
.../MarkdownRendererImpl.performance.test.tsx | 124 ++++++++++++++++++
.../chat/MarkdownRendererImpl.test.ts | 36 ++++-
.../components/chat/MarkdownRendererImpl.tsx | 96 +++++++++++++-
.../src/components/chat/markdown/decorate.ts | 7 +-
.../markdown/detachedMarkdownDomCache.test.ts | 92 +++++++++++++
.../chat/markdown/detachedMarkdownDomCache.ts | 119 +++++++++++++++++
6 files changed, 469 insertions(+), 5 deletions(-)
create mode 100644 packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts
create mode 100644 packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
index f443f91c..9794f7c3 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
@@ -2,6 +2,7 @@ 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';
+import type { TextPart } from '@opencode-ai/sdk/v2';
type OperationCounts = {
innerHTMLWrites: number;
@@ -64,9 +65,13 @@ let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: numb
let MarkdownRenderer: React.ComponentType<{
content: string;
messageId: string;
+ part?: TextPart;
isAnimated?: boolean;
+ isStreaming?: boolean;
enableFileReferences?: boolean;
}>;
+let clearDetachedMarkdownDomCache: () => void;
+let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
const makeCounts = (): OperationCounts => ({
innerHTMLWrites: 0,
@@ -293,6 +298,9 @@ const initializePerformanceDom = async (): Promise => {
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
const imported = await import('./MarkdownRendererImpl');
MarkdownRenderer = imported.MarkdownRenderer;
+ const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
+ clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
+ detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
};
await initializePerformanceDom();
@@ -305,6 +313,122 @@ afterAll(() => {
});
describe('MarkdownRenderer DOM mount performance contract', () => {
+ test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
+ clearDetachedMarkdownDomCache();
+ const content = '# Cached viewport\n\nA settled paragraph.';
+ const part: TextPart = {
+ id: 'part-cache',
+ sessionID: 'session-cache',
+ messageID: 'message-cache',
+ type: 'text',
+ text: content,
+ time: { start: 0, end: 1 },
+ };
+ const host = document.createElement('div');
+ document.body.replaceChildren(host);
+ const render = (root: Root) => root.render(
+ ,
+ );
+
+ const firstCounts = makeCounts();
+ activeCounts = firstCounts;
+ const firstRoot = createRoot(host);
+ await act(async () => {
+ render(firstRoot);
+ await waitForSettledEffects();
+ });
+ const originalBlock = host.querySelector('[data-md-block]');
+ expect(originalBlock).not.toBeNull();
+ expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
+ await act(async () => firstRoot.unmount());
+
+ const secondCounts = makeCounts();
+ activeCounts = secondCounts;
+ const secondRoot = createRoot(host);
+ await act(async () => {
+ render(secondRoot);
+ await waitForSettledEffects();
+ });
+ expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
+ expect(secondCounts.innerHTMLWrites).toBe(0);
+ await act(async () => secondRoot.unmount());
+ clearDetachedMarkdownDomCache();
+ });
+
+ test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
+ clearDetachedMarkdownDomCache();
+ const host = document.createElement('div');
+ document.body.replaceChildren(host);
+ const renderScoped = (
+ root: Root,
+ content: string,
+ partId: string,
+ isStreaming = false,
+ ) => root.render(
+ ,
+ );
+
+ const streamingRoot = createRoot(host);
+ await act(async () => {
+ renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
+ await waitForSettledEffects();
+ });
+ await act(async () => streamingRoot.unmount());
+ expect(detachedMarkdownDomCacheStats().entries).toBe(0);
+
+ const unfinalizedRoot = createRoot(host);
+ await act(async () => {
+ unfinalizedRoot.render(
+ ,
+ );
+ await waitForSettledEffects();
+ });
+ await act(async () => unfinalizedRoot.unmount());
+ expect(detachedMarkdownDomCacheStats().entries).toBe(0);
+
+ const mermaidRoot = createRoot(host);
+ await act(async () => {
+ renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
+ await waitForSettledEffects();
+ });
+ await act(async () => mermaidRoot.unmount());
+ expect(detachedMarkdownDomCacheStats().entries).toBe(0);
+
+ clearDetachedMarkdownDomCache();
+ });
+
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
const mounted = await mountFixture(fixtureWorkload.rendererCount);
const critical = mounted.counts;
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
index b9934615..06911b4e 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
@@ -39,6 +39,7 @@ let mermaidRegistryCreates = 0;
let mermaidRegistryCleanups = 0;
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
+let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
let currentContextVersion = 0;
const layoutEffects: Array<() => void> = [];
const passiveEffects: Array<() => void | (() => void)> = [];
@@ -250,7 +251,7 @@ mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
mock.module('./markdown/markdownCore', () => ({
getCachedMarkdownBlocks: () => cachedRendererBlocks,
- renderMarkdownBlocks: async () => renderedRendererBlocks,
+ renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
renderMarkdownSync: () => {
syncRenderCalls += 1;
return 'cold
';
@@ -258,6 +259,13 @@ mock.module('./markdown/markdownCore', () => ({
}));
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
+mock.module('./markdown/detachedMarkdownDomCache', () => ({
+ detachedMarkdownDomCache: {
+ take: () => null,
+ store: () => undefined,
+ },
+}));
+mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
type TestDecorateContext = {
labels: { copy: string };
codeBlockLineWrap: boolean;
@@ -297,6 +305,7 @@ const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
const resetRendererTestState = () => {
cachedRendererBlocks = null;
renderedRendererBlocks = [];
+ renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
@@ -554,4 +563,29 @@ describe('MarkdownRenderer warm settled path', () => {
});
});
+ test('rejects an older async render after a newer layout commit', async () => {
+ await withRendererDom(async () => {
+ resetRendererTestState();
+ cachedRendererBlocks = [{ id: 'full:initial', html: 'initial
' }];
+ let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
+ const oldRender = new Promise>((resolve) => {
+ resolveOldRender = resolve;
+ });
+ renderMarkdownBlocksForTest = () => oldRender;
+
+ beginRendererRender();
+ runRendererLayoutEffects();
+ runRendererPassiveEffects();
+
+ cachedRendererBlocks = [{ id: 'full:new', html: 'new
' }];
+ beginRendererRender();
+ runRendererLayoutEffects();
+ expect(resolveOldRender).toBeDefined();
+ resolveOldRender?.([{ id: 'full:old-late', html: 'old late
' }]);
+ await Promise.resolve();
+
+ expect(morphCalls).toBe(0);
+ });
+ });
+
});
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
index ea9e3ce3..311eb2c1 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
@@ -50,6 +50,8 @@ import {
} from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
+import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
+import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -667,6 +669,7 @@ const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
const MARKDOWN_DECORATION_IDS = new WeakMap();
let nextMarkdownDecorationId = 0;
+const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
@@ -760,6 +763,7 @@ const useMorphdomMarkdown = ({
imageMode = 'inline',
syntaxVars,
ctx,
+ domCacheKey,
}: {
containerRef: React.RefObject;
text: string;
@@ -767,12 +771,20 @@ const useMorphdomMarkdown = ({
imageMode?: MarkdownImageMode;
syntaxVars: Record;
ctx: DecorateContext;
+ domCacheKey?: DetachedMarkdownDomKey | null;
}) => {
React.useEffect(() => {
ensureMarkdownShikiTheme();
}, []);
const mermaidViewerRef = React.useRef | null>(null);
+ const renderRevisionRef = React.useRef(0);
+ // Only DOM that was actually restored or completed by the async pipeline is
+ // eligible for capture. A fallback from an earlier content revision is not.
+ const mountedDomRef = React.useRef<{
+ key: DetachedMarkdownDomKey;
+ copiedLabel: string;
+ } | null>(null);
const refreshMermaidViewers = React.useCallback(() => {
const container = containerRef.current;
if (!container) {
@@ -788,6 +800,61 @@ const useMorphdomMarkdown = ({
mermaidViewerRef.current.refresh();
}, [containerRef]);
+ React.useLayoutEffect(() => {
+ renderRevisionRef.current += 1;
+ mountedDomRef.current = null;
+ }, [ctx, imageMode, streaming, text]);
+
+ React.useLayoutEffect(() => {
+ if (!domCacheKey) return;
+ const container = containerRef.current;
+ const target = container?.querySelector('[data-markdown-content]') ?? container;
+ if (!target || target.childNodes.length > 0) return;
+
+ const cached = detachedMarkdownDomCache.take(domCacheKey);
+ if (cached) {
+ target.appendChild(cached);
+ const decorationId = getMarkdownDecorationId(ctx);
+ for (const block of Array.from(target.children)) {
+ block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ }
+ for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
+ applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
+ mountedDomRef.current = {
+ key: domCacheKey,
+ copiedLabel: ctx.labels.copied,
+ };
+ streamPerfCount('ui.markdown_renderer.dom_cache.hit');
+ }
+ }, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
+
+ // Restoration follows the cache identity above, but capture must only happen
+ // when this renderer lifecycle ends. Combining both in one keyed effect would
+ // detach the live DOM on ordinary content, theme, or locale updates.
+ React.useLayoutEffect(() => {
+ const container = containerRef.current;
+ const target = container?.querySelector('[data-markdown-content]') ?? container;
+ if (!target) return;
+ return () => {
+ const mountedDom = mountedDomRef.current;
+ if (!mountedDom) return;
+ // Viewer controllers and transient interaction state belong to the
+ // current renderer instance and must not cross the cache boundary.
+ if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
+ if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
+ if (target.querySelector('[data-md-copy-pending]')) return;
+ const openMenu = target.querySelector('[data-md-menu]:not(.hidden)');
+ const copiedButton = Array.from(target.querySelectorAll('[data-md-action]'))
+ .some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
+ if (openMenu || copiedButton) return;
+
+ const fragment = document.createDocumentFragment();
+ fragment.append(...Array.from(target.childNodes));
+ detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
+ streamPerfCount('ui.markdown_renderer.dom_cache.capture');
+ };
+ }, [containerRef]);
+
// Synchronous first paint: while the async parse is in-flight, show escaped
// plain text immediately so there is no blank frame on initial mount. Only
// runs when the target is empty — subsequent updates keep the prior rich DOM
@@ -842,10 +909,11 @@ const useMorphdomMarkdown = ({
if (!container) return;
const target = container.querySelector('[data-markdown-content]') ?? container;
let active = true;
+ const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
- if (!active) return;
+ if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
blocks.forEach((block, index) => {
@@ -907,12 +975,15 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
+ mountedDomRef.current = domCacheKey
+ ? { key: domCacheKey, copiedLabel: ctx.labels.copied }
+ : null;
});
return () => {
active = false;
};
- }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
+ }, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
@@ -993,6 +1064,24 @@ const MarkdownRendererImpl: React.FC = ({
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
+ const { locale } = useI18n();
+ const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
+ const settledPart = part
+ && (part.type === 'text' || part.type === 'reasoning')
+ && part.time?.end !== undefined
+ ? part
+ : null;
+ const runtimeKey = getRuntimeKey();
+ const domCacheKey = React.useMemo(() => {
+ // Streaming, unfinished, oversized, and identity-less Markdown continues
+ // through the normal rendering pipeline and never retains detached DOM.
+ if (isStreaming || !settledPart || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
+ return {
+ scope: `${runtimeKey}\0${settledPart.sessionID}`,
+ id: `${settledPart.messageID}\0${settledPart.id}\0${imageMode}`,
+ locale,
+ };
+ }, [content.length, imageMode, isStreaming, locale, runtimeKey, settledPart]);
// Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
@@ -1000,9 +1089,10 @@ const MarkdownRendererImpl: React.FC = ({
containerRef,
text: content,
streaming: live,
- imageMode: variant === 'assistant' ? 'label' : 'inline',
+ imageMode,
syntaxVars,
ctx,
+ domCacheKey,
});
const markdownContent = (
diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts
index 6dba0f2b..405616f8 100644
--- a/packages/ui/src/components/chat/markdown/decorate.ts
+++ b/packages/ui/src/components/chat/markdown/decorate.ts
@@ -554,7 +554,12 @@ export const attachMarkdownInteractions = (
if (action === 'copy-code') {
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
const text = code ? getMarkdownCodeText(code) : '';
- if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
+ if (text) {
+ actionEl.setAttribute('data-md-copy-pending', '');
+ void copyTextToClipboard(text)
+ .then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
+ .finally(() => actionEl.removeAttribute('data-md-copy-pending'));
+ }
return;
}
diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts
new file mode 100644
index 00000000..128a5026
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, test } from 'bun:test';
+import { Window } from 'happy-dom';
+
+import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
+
+Object.assign(globalThis, { document: new Window().document });
+
+const keyFor = ({ scope, id, locale }: DetachedMarkdownDom) => ({ scope, id, locale });
+
+const createEntry = (
+ document: Document,
+ sessionId: string,
+ messageId: string,
+ partId: string,
+): DetachedMarkdownDom => {
+ const fragment = document.createDocumentFragment();
+ const node = document.createElement('p');
+ node.textContent = `${messageId}:${partId}`;
+ fragment.appendChild(node);
+ return {
+ scope: `runtime:${sessionId}`,
+ id: `${messageId}:${partId}`,
+ locale: 'en',
+ fragment,
+ };
+};
+
+describe('DetachedMarkdownDomCache', () => {
+ test('consumes the original DOM fragment once and rejects another locale', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
+ const originalNode = entry.fragment.firstChild;
+
+ cache.store(entry);
+ expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
+ cache.store(entry);
+ const restored = cache.take(keyFor(entry));
+ expect(restored?.firstChild).toBe(originalNode);
+ expect(cache.take(keyFor(entry))).toBeNull();
+ });
+
+ test('bounds entries per session and evicts the least recently used session', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
+ cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
+ cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
+ cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
+ cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
+ expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
+ expect(cache.take({
+ scope: 'runtime:session-a',
+ id: 'message-2:part',
+ locale: 'en',
+ })).toBeNull();
+ expect(cache.take({
+ scope: 'runtime:session-c',
+ id: 'message-5:part',
+ locale: 'en',
+ })).not.toBeNull();
+ });
+
+ test('isolates identities by runtime and replaces an identity without growing stats', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const first = createEntry(document, 'session', 'message', 'part');
+ const replacement = createEntry(document, 'session', 'message', 'part');
+ const replacementNode = replacement.fragment.firstChild;
+ const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
+
+ cache.store(first);
+ cache.store(replacement);
+ cache.store(otherRuntime);
+
+ expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
+ expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
+ expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
+ });
+
+ test('refreshes session LRU and clears all entries', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
+ const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
+ const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
+ cache.store(sessionA);
+ cache.store(sessionB);
+ cache.store(sessionA);
+ cache.store(sessionC);
+ expect(cache.take(keyFor(sessionB))).toBeNull();
+
+ cache.clear();
+ expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
+ });
+});
diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts
new file mode 100644
index 00000000..8ec194ea
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts
@@ -0,0 +1,119 @@
+export type DetachedMarkdownDomKey = {
+ scope: string;
+ id: string;
+ locale: string;
+};
+
+export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
+ // The fragment owns the original nodes. take() consumes it once by moving
+ // those nodes back into a renderer; nothing is cloned or serialized.
+ fragment: DocumentFragment;
+};
+
+export type DetachedMarkdownDomCacheStats = {
+ sessions: number;
+ entries: number;
+};
+
+// Holds detached, fully decorated Markdown DOM. The cache is intentionally
+// small: it accelerates recent-session and reverse-scroll remounts without
+// retaining whole session trees or depending on browser-specific byte guesses.
+type DetachedMarkdownDomCacheLimits = {
+ maxSessions: number;
+ maxEntriesPerSession: number;
+};
+
+type SessionCache = Map;
+
+const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
+ // Three buckets cover the common A/B/C recent-session rotation without
+ // coupling eviction to React commit or microtask timing.
+ maxSessions: 3,
+ maxEntriesPerSession: 12,
+};
+
+export class DetachedMarkdownDomCache {
+ private readonly maxSessions: number;
+ private readonly maxEntriesPerSession: number;
+ private readonly sessions = new Map();
+
+ constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
+ this.maxSessions = Math.max(1, limits.maxSessions);
+ this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
+ }
+
+ store(entry: DetachedMarkdownDom): void {
+ const sessionKey = entry.scope;
+ const entryKey = entry.id;
+
+ let session = this.sessions.get(sessionKey);
+ if (session === undefined) {
+ session = new Map();
+ this.sessions.set(sessionKey, session);
+ } else {
+ this.refreshSession(sessionKey, session);
+ }
+
+ // A part has one DOM version inside its authoritative runtime/session.
+ session.delete(entryKey);
+ session.set(entryKey, entry);
+
+ while (session.size > this.maxEntriesPerSession) {
+ this.removeOldestEntry(session);
+ }
+ while (this.sessions.size > this.maxSessions) {
+ this.removeOldestSession();
+ }
+ }
+
+ take(key: DetachedMarkdownDomKey): DocumentFragment | null {
+ const sessionKey = key.scope;
+ const session = this.sessions.get(sessionKey);
+ if (!session) return null;
+ const entryKey = key.id;
+
+ this.refreshSession(sessionKey, session);
+ const entry = session.get(entryKey);
+ if (entry === undefined) return null;
+
+ // A fragment is a move-only resource; taking it removes cache ownership.
+ session.delete(entryKey);
+ if (session.size === 0) this.sessions.delete(sessionKey);
+ if (entry.locale !== key.locale) return null;
+ return entry.fragment;
+ }
+
+ clear(): void {
+ this.sessions.clear();
+ }
+
+ stats(): DetachedMarkdownDomCacheStats {
+ let entries = 0;
+ for (const session of this.sessions.values()) {
+ entries += session.size;
+ }
+ return {
+ sessions: this.sessions.size,
+ entries,
+ };
+ }
+
+ private refreshSession(sessionKey: string, session: SessionCache): void {
+ this.sessions.delete(sessionKey);
+ this.sessions.set(sessionKey, session);
+ }
+
+ private removeOldestEntry(session: SessionCache): void {
+ const oldestKey = session.keys().next().value;
+ if (oldestKey === undefined) return;
+ session.delete(oldestKey);
+ }
+
+ private removeOldestSession(): void {
+ const oldestKey = this.sessions.keys().next().value;
+ if (oldestKey === undefined) return;
+ this.sessions.delete(oldestKey);
+ }
+}
+
+export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
From ed37d8e24985fd26f65e02af504fe34a448f62ba Mon Sep 17 00:00:00 2001
From: c_w_xiaohei <1641233466@qq.com>
Date: Sun, 23 Aug 2026 02:47:31 +0800
Subject: [PATCH 06/19] perf(ui): isolate sidebar session selection
---
.../sidebar/list/SessionProjectCollection.tsx | 6 +-
.../sidebar/list/useSessionPrefetch.test.tsx | 2 +-
.../sidebar/list/useSessionPrefetch.ts | 22 +-
packages/ui/src/sync/session-records.test.ts | 54 +++++
packages/ui/src/sync/session-records.ts | 91 ++++++++
.../sync-context-selection-boundary.test.tsx | 67 ++++++
packages/ui/src/sync/sync-context.tsx | 60 ++---
packages/ui/src/sync/use-sync.ts | 208 ++++++++----------
8 files changed, 351 insertions(+), 159 deletions(-)
create mode 100644 packages/ui/src/sync/session-records.test.ts
create mode 100644 packages/ui/src/sync/session-records.ts
create mode 100644 packages/ui/src/sync/sync-context-selection-boundary.test.tsx
diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx
index efc736a9..5f0c6cef 100644
--- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx
+++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
-import { useSync } from '@/sync/use-sync';
+import { usePrefetchSessionMessages } from '@/sync/use-sync';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
@@ -148,7 +148,7 @@ const VisibleSessionProjects: React.FC = ({ topol
if (sessionId === useSessionUIStore.getState().currentSessionId) return;
setCurrentSession(sessionId, sessionDirectory);
}, [setCurrentSession]);
- const sync = useSync();
+ const prefetchSession = usePrefetchSessionMessages();
const { buildGroupedSessions, filterSessionNodesForSearch, buildGroupSearchText } = useSessionGrouping({
homeDirectory: view.homeDirectory,
worktreeMetadata: topology.worktreeMetadata,
@@ -487,7 +487,7 @@ const VisibleSessionProjects: React.FC = ({ topol
{
currentSessionId: current.id,
sortedSessions: [current, nearby],
recentSessions: [current, nearby],
- prefetchSession: async (sessionId) => { calls.push(sessionId); },
+ prefetchSession: async ({ sessionID }) => { calls.push(sessionID); },
});
return null;
};
diff --git a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts
index c8dc60c5..13a1eeae 100644
--- a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts
+++ b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts
@@ -14,7 +14,7 @@ type Args = {
currentSessionId: string | null;
sortedSessions: Session[];
recentSessions?: Session[];
- prefetchSession: (sessionId: string, directory: string) => Promise;
+ prefetchSession: (target: { directory: string; sessionID: string }) => Promise;
};
type PrefetchRequest = {
@@ -23,6 +23,10 @@ type PrefetchRequest = {
generation: number;
};
+const getPrefetchRequestKey = (request: Pick): string => (
+ `${request.directory}\n${request.sessionId}`
+);
+
const sessionDirectory = (session: Session | null | undefined): string | null => {
const directory = session?.directory?.trim();
return directory || null;
@@ -35,10 +39,6 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const generationRef = React.useRef(0);
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
- const requestKey = React.useCallback((request: Pick) => (
- `${request.directory}\n${request.sessionId}`
- ), []);
-
const clearPendingPrefetches = React.useCallback(() => {
generationRef.current += 1;
sessionPrefetchQueueRef.current = [];
@@ -68,16 +68,16 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
continue;
}
- const key = requestKey(request);
+ const key = getPrefetchRequestKey(request);
sessionPrefetchInFlightRef.current.add(key);
- void prefetchSession(request.sessionId, request.directory)
+ void prefetchSession({ directory: request.directory, sessionID: request.sessionId })
.catch(() => undefined)
.finally(() => {
sessionPrefetchInFlightRef.current.delete(key);
pumpSessionPrefetchQueue();
});
}
- }, [enabled, prefetchDisabled, prefetchSession, requestKey]);
+ }, [enabled, prefetchDisabled, prefetchSession]);
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
const sessionId = session?.id;
@@ -86,7 +86,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
return;
}
const request = { sessionId, directory, generation: generationRef.current };
- const key = requestKey(request);
+ const key = getPrefetchRequestKey(request);
// Already renderable in sync
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
@@ -97,7 +97,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
return;
}
- if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
+ if (sessionPrefetchQueueRef.current.some((candidate) => getPrefetchRequestKey(candidate) === key)) {
return;
}
@@ -117,7 +117,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
pumpSessionPrefetchQueue();
}, SESSION_PREFETCH_HOVER_DELAY_MS);
sessionPrefetchTimersRef.current.set(key, timer);
- }, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
+ }, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue]);
React.useEffect(() => {
clearPendingPrefetches();
diff --git a/packages/ui/src/sync/session-records.test.ts b/packages/ui/src/sync/session-records.test.ts
new file mode 100644
index 00000000..8a41c3d6
--- /dev/null
+++ b/packages/ui/src/sync/session-records.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from "bun:test"
+import type { Session } from "@opencode-ai/sdk/v2"
+import { upsertSessionRecord } from "./session-records"
+
+const session = (id: string, overrides: Partial = {}): Session => ({
+ id, slug: id, projectID: "project", directory: "/workspace", title: id, version: "1",
+ time: { created: 1, updated: 1 }, ...overrides,
+})
+
+describe("upsertSessionRecord", () => {
+ test("inserts missing IDs in binary order", () => {
+ expect(upsertSessionRecord([session("a"), session("c")], session("b")).map((item) => item.id)).toEqual(["a", "b", "c"])
+ })
+
+ test("preserves references for separately allocated equivalent metadata", () => {
+ const current = [session("a", {
+ metadata: { nested: ["value", { count: 1 }] },
+ summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
+ }), session("b")]
+ const incoming = session("a", {
+ metadata: { nested: ["value", { count: 1 }] },
+ summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
+ })
+ const result = upsertSessionRecord(current, incoming)
+ expect(result).toBe(current)
+ expect(result[0]).toBe(current[0])
+ expect(result[1]).toBe(current[1])
+ })
+
+ const changes: Array<[string, Partial, Partial]> = [
+ ["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }],
+ ["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }],
+ ["share and model", { share: { url: "a" }, model: { id: "m", providerID: "p", variant: "a" } }, { share: { url: "b" }, model: { id: "m", providerID: "p", variant: "b" } }],
+ ["metadata", { metadata: { key: "a" } }, { metadata: { key: "b" } }],
+ ["permission", { permission: [{ permission: "bash", pattern: "*", action: "ask" }] }, { permission: [{ permission: "bash", pattern: "*", action: "allow" }] }],
+ ["revert", { revert: { messageID: "m", partID: "a", snapshot: "s", diff: "d" } }, { revert: { messageID: "m", partID: "b", snapshot: "s", diff: "d" } }],
+ ["summary diffs", { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] } }, { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 2, deletions: 0 }] } }],
+ ["time", { time: { created: 1, updated: 1, compacting: 2, archived: 3 } }, { time: { created: 1, updated: 2, compacting: 2, archived: 3 } }],
+ ]
+
+ for (const [field, current, incoming] of changes) {
+ test(`replaces only target when ${field} changes`, () => {
+ const first = session("a")
+ const target = session("b", current)
+ const last = session("c")
+ const list = [first, target, last]
+ const result = upsertSessionRecord(list, session("b", incoming))
+ expect(result).not.toBe(list)
+ expect(result[0]).toBe(first)
+ expect(result[1]).not.toBe(target)
+ expect(result[2]).toBe(last)
+ })
+ }
+})
diff --git a/packages/ui/src/sync/session-records.ts b/packages/ui/src/sync/session-records.ts
new file mode 100644
index 00000000..1e6a3ce3
--- /dev/null
+++ b/packages/ui/src/sync/session-records.ts
@@ -0,0 +1,91 @@
+import type { PermissionRuleset, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import { Binary } from "./binary"
+
+function areMetadataEqual(left: Session["metadata"], right: Session["metadata"]): boolean {
+ return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
+}
+
+function optionalEqual(
+ left: T | undefined,
+ right: T | undefined,
+ equal: (left: T, right: T) => boolean,
+): boolean {
+ return left === right || (left !== undefined && right !== undefined && equal(left, right))
+}
+
+const diffsEqual = (left: SnapshotFileDiff[], right: SnapshotFileDiff[]) => (
+ left.length === right.length
+ && left.every((item, index) => {
+ const candidate = right[index]
+ return item.file === candidate.file
+ && item.patch === candidate.patch
+ && item.additions === candidate.additions
+ && item.deletions === candidate.deletions
+ && item.status === candidate.status
+ })
+)
+
+const permissionsEqual = (left: PermissionRuleset, right: PermissionRuleset) => (
+ left.length === right.length
+ && left.every((item, index) => {
+ const candidate = right[index]
+ return item.permission === candidate.permission
+ && item.pattern === candidate.pattern
+ && item.action === candidate.action
+ })
+)
+
+function areSessionsEqual(left: Session, right: Session): boolean {
+ return left.id === right.id
+ && left.slug === right.slug
+ && left.projectID === right.projectID
+ && left.workspaceID === right.workspaceID
+ && left.directory === right.directory
+ && left.path === right.path
+ && left.parentID === right.parentID
+ && left.cost === right.cost
+ && left.title === right.title
+ && left.agent === right.agent
+ && left.version === right.version
+ && areMetadataEqual(left.metadata, right.metadata)
+ && optionalEqual(left.summary, right.summary, (a, b) => (
+ a.additions === b.additions
+ && a.deletions === b.deletions
+ && a.files === b.files
+ && optionalEqual(a.diffs, b.diffs, diffsEqual)
+ ))
+ && optionalEqual(left.tokens, right.tokens, (a, b) => (
+ a.input === b.input
+ && a.output === b.output
+ && a.reasoning === b.reasoning
+ && a.cache.read === b.cache.read
+ && a.cache.write === b.cache.write
+ ))
+ && optionalEqual(left.share, right.share, (a, b) => a.url === b.url)
+ && optionalEqual(left.model, right.model, (a, b) => (
+ a.id === b.id
+ && a.providerID === b.providerID
+ && a.variant === b.variant
+ ))
+ && left.time.created === right.time.created
+ && left.time.updated === right.time.updated
+ && left.time.compacting === right.time.compacting
+ && left.time.archived === right.time.archived
+ && optionalEqual(left.permission, right.permission, permissionsEqual)
+ && optionalEqual(left.revert, right.revert, (a, b) => (
+ a.messageID === b.messageID
+ && a.partID === b.partID
+ && a.snapshot === b.snapshot
+ && a.diff === b.diff
+ ))
+}
+
+export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
+ const result = Binary.search(current, incoming.id, (session) => session.id)
+ if (!result.found) return [...current.slice(0, result.index), incoming, ...current.slice(result.index)]
+ // Equivalent authoritative detail must retain sidebar session-list references.
+ if (areSessionsEqual(current[result.index], incoming)) return current
+ const next = [...current]
+ next[result.index] = incoming
+ return next
+}
diff --git a/packages/ui/src/sync/sync-context-selection-boundary.test.tsx b/packages/ui/src/sync/sync-context-selection-boundary.test.tsx
new file mode 100644
index 00000000..5bf96a48
--- /dev/null
+++ b/packages/ui/src/sync/sync-context-selection-boundary.test.tsx
@@ -0,0 +1,67 @@
+import { describe, expect, test } from 'bun:test'
+import React, { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { createOpencodeClient } from '@opencode-ai/sdk/v2'
+import { SyncProvider, useSyncDirectory } from './sync-context'
+import { usePrefetchSessionMessages } from './use-sync'
+import { installHookTestDom } from '../components/session/sidebar/test-utils/testDom'
+
+const createSdk = () => createOpencodeClient({
+ baseUrl: 'https://sync.test',
+ fetch: async (request) => {
+ const path = new URL(request instanceof Request ? request.url : request.toString()).pathname
+ if (path.endsWith('/global/event')) {
+ return new Response(new ReadableStream(), { headers: { 'content-type': 'text/event-stream' } })
+ }
+ const body = path.endsWith('/path')
+ ? { state: '', config: '', worktree: '/workspace', directory: '/workspace', home: '/home' }
+ : path.endsWith('/project') ? []
+ : path.endsWith('/project/current') ? { id: 'project' }
+ : path.endsWith('/session/status') ? {}
+ : []
+ return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } })
+ },
+})
+
+describe('SyncProvider selection boundary', () => {
+ test('does not rerender a stable prefetch consumer when only current directory changes', async () => {
+ const dom = installHookTestDom()
+ const root = createRoot(dom.container)
+ let runtimeRenders = 0
+ let directoryRenders = 0
+ let callback: ReturnType | undefined
+ const RuntimeConsumer = React.memo(() => {
+ callback = usePrefetchSessionMessages()
+ runtimeRenders += 1
+ return null
+ })
+ const DirectoryConsumer = () => {
+ useSyncDirectory()
+ directoryRenders += 1
+ return null
+ }
+ const sdk = createSdk()
+
+ try {
+ await act(async () => root.render(
+
+
+
+ ,
+ ))
+ const initialCallback = callback
+ await act(async () => root.render(
+
+
+
+ ,
+ ))
+ expect(runtimeRenders).toBe(1)
+ expect(callback).toBe(initialCallback)
+ expect(directoryRenders).toBe(2)
+ } finally {
+ await act(async () => root.unmount())
+ dom.restore()
+ }
+ })
+})
diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx
index 772138a1..b4a30f3f 100644
--- a/packages/ui/src/sync/sync-context.tsx
+++ b/packages/ui/src/sync/sync-context.tsx
@@ -37,6 +37,7 @@ import { setActionRefs } from "./session-actions"
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { useSessionUIStore } from "./session-ui-store"
import { stripSessionDiffSnapshots } from "./sanitize"
+import { upsertSessionRecord } from "./session-records"
import { applySessionEventToGlobalSessions } from "./session-event-router"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
@@ -86,22 +87,29 @@ import {
// Context
// ---------------------------------------------------------------------------
-type SyncSystem = {
+type SyncRuntime = {
childStores: ChildStoreManager
messageLoader: SessionMessageLoader
runtimeKey: string
sdk: OpencodeClient
+}
+
+type SyncSystem = SyncRuntime & {
directory: string
}
const SYNC_CONTEXT_GLOBAL_KEY = "__openchamber_sync_context__"
+const SYNC_RUNTIME_CONTEXT_GLOBAL_KEY = "__openchamber_sync_runtime_context__"
type SyncGlobal = typeof globalThis & {
[SYNC_CONTEXT_GLOBAL_KEY]?: React.Context
+ [SYNC_RUNTIME_CONTEXT_GLOBAL_KEY]?: React.Context
}
const syncGlobal = globalThis as SyncGlobal
const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext(null)
syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext
+const SyncRuntimeContext = syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] ?? createContext(null)
+syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] = SyncRuntimeContext
type SdkResult = {
data?: T
@@ -137,6 +145,12 @@ function useSyncSystem() {
return ctx
}
+export function useSyncRuntime() {
+ const ctx = useContext(SyncRuntimeContext)
+ if (!ctx) throw new Error("useSyncRuntime must be used within ")
+ return ctx
+}
+
function getLiveStates(childStores: ChildStoreManager): State[] {
return Array.from(childStores.children.values(), (store) => store.getState())
}
@@ -1408,28 +1422,13 @@ async function resyncDirectoryAfterReconnect(
const nextSession = stripSessionDiffSnapshots(session)
store.setState((state: DirectoryStore) => {
- const sessionIndex = state.session.findIndex((item) => item.id === nextSession.id)
- let sessions = state.session
- let sessionChanged = false
+ const sessions = upsertSessionRecord(state.session, nextSession)
let sessionTotal = state.sessionTotal
- if (sessionIndex >= 0) {
- if (!haveEquivalentSyncSnapshots(sessions[sessionIndex], nextSession)) {
- sessions = [...state.session]
- sessions[sessionIndex] = nextSession
- sessionChanged = true
- }
- } else {
- sessions = [...state.session]
- sessions.push(nextSession)
- sessions.sort((a, b) => cmp(a.id, b.id))
- if (!nextSession.parentID) sessionTotal += 1
- sessionChanged = true
- }
-
- if (!sessionChanged) {
+ if (sessions === state.session) {
return state
}
+ if (!state.session.some((item) => item.id === nextSession.id) && !nextSession.parentID) sessionTotal += 1
return {
session: sessions,
@@ -2024,15 +2023,13 @@ export function SyncProvider(props: {
const pipelineHasConnectedRef = useRef(false)
const pipelineDisconnectedBeforeFirstConnectRef = useRef(false)
+ const runtime = useMemo(
+ () => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }),
+ [childStores, messageLoader, props.sdk, runtimeKey],
+ )
const system = useMemo(
- () => ({
- childStores,
- messageLoader,
- runtimeKey,
- sdk: props.sdk,
- directory: props.directory,
- }),
- [childStores, messageLoader, props.sdk, props.directory, runtimeKey],
+ () => ({ ...runtime, directory: props.directory }),
+ [props.directory, runtime],
)
const triggerDirectoryResync = useCallback((directory: string, reason: SessionMaterializationReason) => {
@@ -2547,7 +2544,14 @@ export function SyncProvider(props: {
return unsubscribe
}, [props.directory, childStores])
- return {props.children}
+ // Directory navigation must not republish stable runtime dependencies.
+ return (
+
+
+ {props.children}
+
+
+ )
}
// ---------------------------------------------------------------------------
diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts
index 629821f7..e5ea35e6 100644
--- a/packages/ui/src/sync/use-sync.ts
+++ b/packages/ui/src/sync/use-sync.ts
@@ -1,9 +1,10 @@
import { useCallback, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
+import { upsertSessionRecord } from "./session-records"
import { retry } from "./retry"
import { SESSION_CACHE_LIMIT, type State } from "./types"
-import { pickSessionCacheEvictions } from "./session-cache"
+import { dropSessionCaches, getProtectedSessionCacheIds, pickSessionCacheEvictions } from "./session-cache"
import {
dropCachedSessionMessageRecordsSnapshots,
useChildStoreManager,
@@ -11,9 +12,9 @@ import {
useSessionMessageLoader,
useSyncDirectory,
useSyncSDK,
+ useSyncRuntime,
resyncBlockingRequestsForDirectory,
} from "./sync-context"
-import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
@@ -46,7 +47,6 @@ const syncSessionInflightByKey = new Map>()
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
// sidebar) from having each completed fetch fight for focus.
const syncSessionGenerationByKey = new Map()
-
type SdkResult = {
data?: T
error?: unknown
@@ -111,10 +111,80 @@ export function shouldFetchSessionForRenderableSync(input: {
return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages
}
-// ---------------------------------------------------------------------------
-// useSync — message loading, pagination, optimistic updates
-// Message loading, pagination, optimistic updates
-// ---------------------------------------------------------------------------
+function useSessionCacheTouch() {
+ const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
+
+ const evict = useCallback(
+ (directory: string, sessionIDs: string[]) => {
+ if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
+ const store = childStores.getChild(directory)
+ if (!store) return
+
+ const current = store.getState()
+ const draft = {
+ message: { ...current.message },
+ part: { ...current.part },
+ session_status: { ...current.session_status },
+ session_diff: { ...current.session_diff },
+ todo: { ...current.todo },
+ permission: { ...current.permission },
+ question: { ...current.question },
+ }
+ dropSessionCaches(draft, sessionIDs)
+ dropCachedSessionMessageRecordsSnapshots(store, sessionIDs)
+ store.setState(draft)
+ for (const sessionID of sessionIDs) messageLoader.invalidateSession({ directory, sessionID })
+ clearSessionPrefetch(directory, sessionIDs)
+ },
+ [childStores, messageLoader, runtimeKey],
+ )
+
+ const seenFor = useCallback((directory: string) => {
+ const cacheKey = `${runtimeKey}\n${directory}`
+ const existing = seenByDirectory.get(cacheKey)
+ if (existing) {
+ seenByDirectory.delete(cacheKey)
+ seenByDirectory.set(cacheKey, existing)
+ return existing.sessions
+ }
+ const created: SeenDirectoryEntry = { runtimeKey, directory, sessions: new Set() }
+ seenByDirectory.set(cacheKey, created)
+ while (seenByDirectory.size > MAX_SEEN_DIRS) {
+ const oldestKey = seenByDirectory.keys().next().value
+ if (!oldestKey) break
+ const oldest = seenByDirectory.get(oldestKey)
+ seenByDirectory.delete(oldestKey)
+ if (oldest?.runtimeKey === runtimeKey) evict(oldest.directory, [...oldest.sessions])
+ }
+ return created.sessions
+ }, [evict, runtimeKey])
+
+ return useCallback((sessionID: string, directory: string) => {
+ if (getRuntimeKey() !== runtimeKey) return
+ const seen = seenFor(directory)
+ const store = childStores.ensureChild(directory, { bootstrap: false })
+ const protectedIds = getProtectedSessionCacheIds(store.getState())
+ const stale = pickSessionCacheEvictions({
+ seen,
+ keep: sessionID,
+ limit: getEffectiveSessionCacheLimit(),
+ preserve: protectedIds,
+ })
+ evict(directory, stale)
+
+ if (!isConstrainedSessionRuntime()) return
+ const state = store.getState()
+ const keep = new Set([sessionID, ...seen, ...protectedIds])
+ const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
+ evict(directory, prefetched)
+ const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
+ const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => (
+ id !== sessionID && !protectedIds.has(id) && isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
+ ))
+ for (const id of heavyInactive) seen.delete(id)
+ evict(directory, heavyInactive)
+ }, [childStores, evict, runtimeKey, seenFor])
+}
export function useSync() {
const sdk = useSyncSDK()
@@ -123,6 +193,7 @@ export function useSync() {
const childStores = useChildStoreManager()
const messageLoader = useSessionMessageLoader()
const runtimeKey = getRuntimeKey()
+ const touch = useSessionCacheTouch()
const recoverPendingQuestions = useCallback(
async (sessionID: string, directoryOverride?: string): Promise => {
@@ -146,107 +217,6 @@ export function useSync() {
[directory, runtimeKey],
)
- // Session cache eviction — two levels of LRU:
- // (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT).
-
- // Evict all cached session data for given IDs from a directory's store
- const evict = useCallback(
- (dir: string, sessionIDs: string[]) => {
- if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
- const dirStore = childStores.getChild(dir)
- if (!dirStore) return
-
- const current = dirStore.getState()
- const draft = {
- message: { ...current.message },
- part: { ...current.part },
- session_status: { ...current.session_status },
- session_diff: { ...current.session_diff },
- todo: { ...current.todo },
- permission: { ...current.permission },
- question: { ...current.question },
- }
- dropSessionCaches(draft, sessionIDs)
- dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs)
- dirStore.setState(draft)
-
- // Clear meta + optimistic + prefetch cache for evicted sessions
- for (const id of sessionIDs) {
- messageLoader.invalidateSession({ directory: dir, sessionID: id })
- }
- clearSessionPrefetch(dir, sessionIDs)
- },
- [childStores, messageLoader, runtimeKey],
- )
-
- // Get or create the seen-set for a directory. LRU reorder on access.
- // When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
- // LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
- const seenFor = useCallback((targetDirectory: string) => {
- const cacheKey = `${runtimeKey}\n${targetDirectory}`
- const existing = seenByDirectory.get(cacheKey)
- if (existing) {
- // LRU reorder: delete + re-insert moves to end (most recent)
- seenByDirectory.delete(cacheKey)
- seenByDirectory.set(cacheKey, existing)
- return existing.sessions
- }
- const created: SeenDirectoryEntry = { runtimeKey, directory: targetDirectory, sessions: new Set() }
- seenByDirectory.set(cacheKey, created)
-
- // Evict oldest directories if over limit
- while (seenByDirectory.size > MAX_SEEN_DIRS) {
- const first = seenByDirectory.keys().next().value
- if (!first) break
- const stale = seenByDirectory.get(first)
- seenByDirectory.delete(first)
- if (stale?.runtimeKey === runtimeKey) evict(stale.directory, [...stale.sessions])
- }
-
- return created.sessions
- }, [evict, runtimeKey])
-
- // Touch a session — triggers both directory-level and session-level eviction
- const touch = useCallback(
- (sessionID: string, targetDirectory = directory) => {
- if (getRuntimeKey() !== runtimeKey) return
- const s = seenFor(targetDirectory)
- const targetStore = targetDirectory === directory
- ? store
- : childStores.ensureChild(targetDirectory, { bootstrap: false })
- const protectedIds = getProtectedSessionCacheIds(targetStore.getState())
- const cacheLimit = getEffectiveSessionCacheLimit()
- const stale = pickSessionCacheEvictions({
- seen: s,
- keep: sessionID,
- limit: cacheLimit,
- preserve: protectedIds,
- })
- evict(targetDirectory, stale)
-
- if (isConstrainedSessionRuntime()) {
- const state = targetStore.getState()
- const keep = new Set([sessionID, ...s, ...protectedIds])
- const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
- evict(targetDirectory, prefetched)
-
- // One very large inactive session can create memory/GC pressure that
- // makes later small-session switches feel slow. Keep it while active,
- // but do not retain it as a warm cache in constrained shells.
- const afterPrefetchEviction = prefetched.length > 0 ? targetStore.getState() : state
- const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
- if (id === sessionID || protectedIds.has(id)) return false
- return isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
- })
- if (heavyInactive.length > 0) {
- for (const id of heavyInactive) s.delete(id)
- evict(targetDirectory, heavyInactive)
- }
- }
- },
- [childStores, directory, seenFor, evict, runtimeKey, store],
- )
-
// Sync a session (load if not cached)
const syncSession = useCallback(
async (sessionID: string, force?: boolean, directoryOverride?: string) => {
@@ -289,14 +259,8 @@ export function useSync() {
if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = targetStore.getState()
- const sessions = [...s.session]
- const idx = Binary.search(sessions, sessionID, (s) => s.id)
- if (idx.found) {
- sessions[idx.index] = nextSession
- } else {
- sessions.splice(idx.index, 0, nextSession)
- }
- if (!isStale()) {
+ const sessions = upsertSessionRecord(s.session, nextSession)
+ if (sessions !== s.session && !isStale()) {
targetStore.setState({ session: sessions })
}
}
@@ -436,3 +400,15 @@ export function useSync() {
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, recoverPendingQuestions, optimisticAdd, optimisticRemove, optimisticConfirm],
)
}
+
+export function usePrefetchSessionMessages() {
+ const { messageLoader, runtimeKey } = useSyncRuntime()
+ const touch = useSessionCacheTouch()
+
+ return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
+ if (getRuntimeKey() !== runtimeKey) return
+ await messageLoader.prefetch({ directory, sessionID })
+ if (messageLoader.getSnapshot({ directory, sessionID }).status !== "ready") return
+ touch(sessionID, directory)
+ }, [messageLoader, runtimeKey, touch])
+}
From cd179bd1182bc972aaad200499eb83525b1a1c8c Mon Sep 17 00:00:00 2001
From: c_w_xiaohei <1641233466@qq.com>
Date: Sun, 23 Aug 2026 15:54:18 +0800
Subject: [PATCH 07/19] perf(ui): avoid parsing markdown control icons
---
.../MarkdownRendererImpl.performance.test.tsx | 20 +++++++-
.../src/components/chat/markdown/decorate.ts | 46 ++++++++++---------
2 files changed, 42 insertions(+), 24 deletions(-)
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
index 9794f7c3..e148277b 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx
@@ -6,6 +6,7 @@ import type { TextPart } from '@opencode-ai/sdk/v2';
type OperationCounts = {
innerHTMLWrites: number;
+ spriteIconInnerHTMLWrites: number;
querySelectorAllCalls: number;
appendCalls: number;
replaceCalls: number;
@@ -75,6 +76,7 @@ let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
const makeCounts = (): OperationCounts => ({
innerHTMLWrites: 0,
+ spriteIconInnerHTMLWrites: 0,
querySelectorAllCalls: 0,
appendCalls: 0,
replaceCalls: 0,
@@ -204,7 +206,10 @@ const initializePerformanceDom = async (): Promise