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 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();
|
||||
Reference in New Issue
Block a user