perf(ui): reuse detached markdown DOM

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 1dfa3aee2f
commit 77da97953f
6 changed files with 469 additions and 5 deletions
@@ -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<void> => {
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(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={part}
isAnimated={false}
enableFileReferences={false}
/>,
);
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(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={{
id: partId,
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
isStreaming={isStreaming}
enableFileReferences={false}
/>,
);
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(
<MarkdownRenderer
content="unfinalized content"
messageId="message-unfinalized"
part={{
id: 'part-unfinalized',
sessionID: 'session-cache',
messageID: 'message-unfinalized',
type: 'text',
text: 'unfinalized content',
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
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;
@@ -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 '<p>cold</p>';
@@ -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: '<p>initial</p>' }];
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
resolveOldRender = resolve;
});
renderMarkdownBlocksForTest = () => oldRender;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
beginRendererRender();
runRendererLayoutEffects();
expect(resolveOldRender).toBeDefined();
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
});
@@ -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<DecorateContext, string>();
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<HTMLDivElement | null>;
text: string;
@@ -767,12 +771,20 @@ const useMorphdomMarkdown = ({
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
domCacheKey?: DetachedMarkdownDomKey | null;
}) => {
React.useEffect(() => {
ensureMarkdownShikiTheme();
}, []);
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | 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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-md-menu]:not(.hidden)');
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[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<HTMLElement>('[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<MarkdownRendererProps> = ({
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<DetachedMarkdownDomKey | null>(() => {
// 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<MarkdownRendererProps> = ({
containerRef,
text: content,
streaming: live,
imageMode: variant === 'assistant' ? 'label' : 'inline',
imageMode,
syntaxVars,
ctx,
domCacheKey,
});
const markdownContent = (
@@ -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;
}
@@ -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 });
});
});
@@ -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<string, DetachedMarkdownDom>;
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<string, SessionCache>();
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();