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] 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();