perf(ui): reuse detached markdown DOM
This commit is contained in:
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test';
|
|||||||
import { Window } from 'happy-dom';
|
import { Window } from 'happy-dom';
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import type { TextPart } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
type OperationCounts = {
|
type OperationCounts = {
|
||||||
innerHTMLWrites: number;
|
innerHTMLWrites: number;
|
||||||
@@ -64,9 +65,13 @@ let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: numb
|
|||||||
let MarkdownRenderer: React.ComponentType<{
|
let MarkdownRenderer: React.ComponentType<{
|
||||||
content: string;
|
content: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
part?: TextPart;
|
||||||
isAnimated?: boolean;
|
isAnimated?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
enableFileReferences?: boolean;
|
enableFileReferences?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
let clearDetachedMarkdownDomCache: () => void;
|
||||||
|
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
|
||||||
|
|
||||||
const makeCounts = (): OperationCounts => ({
|
const makeCounts = (): OperationCounts => ({
|
||||||
innerHTMLWrites: 0,
|
innerHTMLWrites: 0,
|
||||||
@@ -293,6 +298,9 @@ const initializePerformanceDom = async (): Promise<void> => {
|
|||||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
|
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
|
||||||
const imported = await import('./MarkdownRendererImpl');
|
const imported = await import('./MarkdownRendererImpl');
|
||||||
MarkdownRenderer = imported.MarkdownRenderer;
|
MarkdownRenderer = imported.MarkdownRenderer;
|
||||||
|
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
|
||||||
|
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
|
||||||
|
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
|
||||||
};
|
};
|
||||||
|
|
||||||
await initializePerformanceDom();
|
await initializePerformanceDom();
|
||||||
@@ -305,6 +313,122 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
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 () => {
|
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
|
||||||
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
||||||
const critical = mounted.counts;
|
const critical = mounted.counts;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ let mermaidRegistryCreates = 0;
|
|||||||
let mermaidRegistryCleanups = 0;
|
let mermaidRegistryCleanups = 0;
|
||||||
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
|
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
|
||||||
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
|
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
|
||||||
|
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||||
let currentContextVersion = 0;
|
let currentContextVersion = 0;
|
||||||
const layoutEffects: Array<() => void> = [];
|
const layoutEffects: Array<() => void> = [];
|
||||||
const passiveEffects: Array<() => void | (() => 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('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
|
||||||
mock.module('./markdown/markdownCore', () => ({
|
mock.module('./markdown/markdownCore', () => ({
|
||||||
getCachedMarkdownBlocks: () => cachedRendererBlocks,
|
getCachedMarkdownBlocks: () => cachedRendererBlocks,
|
||||||
renderMarkdownBlocks: async () => renderedRendererBlocks,
|
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
|
||||||
renderMarkdownSync: () => {
|
renderMarkdownSync: () => {
|
||||||
syncRenderCalls += 1;
|
syncRenderCalls += 1;
|
||||||
return '<p>cold</p>';
|
return '<p>cold</p>';
|
||||||
@@ -258,6 +259,13 @@ mock.module('./markdown/markdownCore', () => ({
|
|||||||
}));
|
}));
|
||||||
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
|
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
|
||||||
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
|
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
|
||||||
|
mock.module('./markdown/detachedMarkdownDomCache', () => ({
|
||||||
|
detachedMarkdownDomCache: {
|
||||||
|
take: () => null,
|
||||||
|
store: () => undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||||
type TestDecorateContext = {
|
type TestDecorateContext = {
|
||||||
labels: { copy: string };
|
labels: { copy: string };
|
||||||
codeBlockLineWrap: boolean;
|
codeBlockLineWrap: boolean;
|
||||||
@@ -297,6 +305,7 @@ const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
|
|||||||
const resetRendererTestState = () => {
|
const resetRendererTestState = () => {
|
||||||
cachedRendererBlocks = null;
|
cachedRendererBlocks = null;
|
||||||
renderedRendererBlocks = [];
|
renderedRendererBlocks = [];
|
||||||
|
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||||
syncRenderCalls = 0;
|
syncRenderCalls = 0;
|
||||||
morphCalls = 0;
|
morphCalls = 0;
|
||||||
decorateCalls = 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';
|
} from './fileReferenceParser';
|
||||||
import { fileReferenceExists } from './fileReferenceStat';
|
import { fileReferenceExists } from './fileReferenceStat';
|
||||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||||
|
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
|
||||||
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
const useCurrentMermaidTheme = () => {
|
const useCurrentMermaidTheme = () => {
|
||||||
const themeSystem = useOptionalThemeSystem();
|
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_ID_ATTR = 'data-md-decoration-id';
|
||||||
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
||||||
let nextMarkdownDecorationId = 0;
|
let nextMarkdownDecorationId = 0;
|
||||||
|
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
||||||
|
|
||||||
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
|
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
|
||||||
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
|
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
|
||||||
@@ -760,6 +763,7 @@ const useMorphdomMarkdown = ({
|
|||||||
imageMode = 'inline',
|
imageMode = 'inline',
|
||||||
syntaxVars,
|
syntaxVars,
|
||||||
ctx,
|
ctx,
|
||||||
|
domCacheKey,
|
||||||
}: {
|
}: {
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -767,12 +771,20 @@ const useMorphdomMarkdown = ({
|
|||||||
imageMode?: MarkdownImageMode;
|
imageMode?: MarkdownImageMode;
|
||||||
syntaxVars: Record<string, string>;
|
syntaxVars: Record<string, string>;
|
||||||
ctx: DecorateContext;
|
ctx: DecorateContext;
|
||||||
|
domCacheKey?: DetachedMarkdownDomKey | null;
|
||||||
}) => {
|
}) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ensureMarkdownShikiTheme();
|
ensureMarkdownShikiTheme();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
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 refreshMermaidViewers = React.useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -788,6 +800,61 @@ const useMorphdomMarkdown = ({
|
|||||||
mermaidViewerRef.current.refresh();
|
mermaidViewerRef.current.refresh();
|
||||||
}, [containerRef]);
|
}, [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
|
// 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
|
// 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
|
// runs when the target is empty — subsequent updates keep the prior rich DOM
|
||||||
@@ -842,10 +909,11 @@ const useMorphdomMarkdown = ({
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
const renderRevision = renderRevisionRef.current;
|
||||||
const decorationId = getMarkdownDecorationId(ctx);
|
const decorationId = getMarkdownDecorationId(ctx);
|
||||||
|
|
||||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||||
if (!active) return;
|
if (!active || renderRevisionRef.current !== renderRevision) return;
|
||||||
const existing = Array.from(target.children) as HTMLElement[];
|
const existing = Array.from(target.children) as HTMLElement[];
|
||||||
|
|
||||||
blocks.forEach((block, index) => {
|
blocks.forEach((block, index) => {
|
||||||
@@ -907,12 +975,15 @@ const useMorphdomMarkdown = ({
|
|||||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||||
refreshMermaidViewers();
|
refreshMermaidViewers();
|
||||||
}
|
}
|
||||||
|
mountedDomRef.current = domCacheKey
|
||||||
|
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||||
|
: null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -993,6 +1064,24 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
|
|
||||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
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.
|
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||||
|
|
||||||
@@ -1000,9 +1089,10 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
containerRef,
|
containerRef,
|
||||||
text: content,
|
text: content,
|
||||||
streaming: live,
|
streaming: live,
|
||||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
imageMode,
|
||||||
syntaxVars,
|
syntaxVars,
|
||||||
ctx,
|
ctx,
|
||||||
|
domCacheKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const markdownContent = (
|
const markdownContent = (
|
||||||
|
|||||||
@@ -554,7 +554,12 @@ export const attachMarkdownInteractions = (
|
|||||||
if (action === 'copy-code') {
|
if (action === 'copy-code') {
|
||||||
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
|
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
|
||||||
const text = code ? getMarkdownCodeText(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;
|
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();
|
||||||
Reference in New Issue
Block a user