perf(ui): reuse warm markdown blocks

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 26dbc2f309
commit 25be29731f
4 changed files with 592 additions and 33 deletions
@@ -1,9 +1,366 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
type FakeElement = {
childNodes: FakeElement[];
children: FakeElement[];
parentNode: FakeElement | null;
attributes: Map<string, string>;
style: { display: string; setProperty: () => void };
innerHTML: string;
setAttribute: (name: string, value: string) => void;
getAttribute: (name: string) => string | null;
appendChild: (child: FakeElement) => FakeElement;
replaceWith: (replacement: FakeElement) => void;
remove: () => void;
querySelector: (selector: string) => FakeElement | null;
querySelectorAll: <T>(selector: string) => T[];
addEventListener: () => void;
removeEventListener: () => void;
contains: (child: FakeElement) => boolean;
isEqualNode: () => boolean;
};
type FakeDocument = { createElement: () => FakeElement };
type FakeJsxProps = {
ref?: { current: FakeElement | null };
children?: FakeElement | FakeElement[];
className?: string;
'data-markdown-content'?: boolean;
};
let syncRenderCalls = 0;
let morphCalls = 0;
let decorateCalls = 0;
let mermaidRegistryCreates = 0;
let mermaidRegistryCleanups = 0;
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
let currentContextVersion = 0;
const layoutEffects: Array<() => void> = [];
const passiveEffects: Array<() => void | (() => void)> = [];
let hookCursor = 0;
let hookStates: Array<{ current: null } | undefined> = [];
let activeFakeDocument: FakeDocument | null = null;
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
let html = '';
const element: FakeElement = {
childNodes: [],
children: [],
parentNode: null,
attributes: new Map(),
style: { display: '', setProperty: () => undefined },
get innerHTML() {
return html;
},
set innerHTML(value: string) {
html = value;
},
setAttribute(name, value) {
this.attributes.set(name, value);
},
getAttribute(name) {
return this.attributes.get(name) ?? null;
},
appendChild(child) {
child.parentNode = this;
this.childNodes.push(child);
this.children.push(child);
return child;
},
replaceWith(replacement) {
if (!this.parentNode) return;
const parent = this.parentNode;
const index = parent.children.indexOf(this);
if (index < 0) return;
replacement.parentNode = parent;
parent.children[index] = replacement;
parent.childNodes[index] = replacement;
this.parentNode = null;
},
remove() {
if (!this.parentNode) return;
const parent = this.parentNode;
parent.children = parent.children.filter((child) => child !== this);
parent.childNodes = parent.childNodes.filter((child) => child !== this);
this.parentNode = null;
},
querySelector(selector) {
if (selector === '[data-markdown-content]') {
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
}
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
return this;
}
for (const child of this.children) {
const match = child.querySelector(selector);
if (match) return match;
}
return null;
},
querySelectorAll: () => [],
addEventListener: () => undefined,
removeEventListener: () => undefined,
contains(child) {
return child === this || this.children.some((candidate) => candidate.contains(child));
},
isEqualNode: () => false,
};
return element;
};
const installRendererDom = () => {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
activeFakeDocument = documentStub;
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
matchMedia: () => ({ matches: false }),
setTimeout,
clearTimeout,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
disconnect() {}
},
});
return () => {
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
else Reflect.deleteProperty(globalThis, 'document');
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
else Reflect.deleteProperty(globalThis, 'MutationObserver');
activeFakeDocument = null;
};
};
const rendererThemes = [{
metadata: { id: 'renderer-test' },
colors: {
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
interactive: { border: '#ccc' },
primary: { base: '#00f' },
},
}, {
metadata: { id: 'renderer-test-next' },
colors: {
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
interactive: { border: '#bbb' },
primary: { base: '#f00' },
},
}];
let rendererThemeIndex = 0;
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
const rendererUiState = {
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
setCodeBlockLineWrap: () => undefined,
openContextPreview: () => undefined,
};
const fakeReact = {
useCallback: <T>(callback: T): T => {
hookCursor += 1;
return callback;
},
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
useMemo: <T>(factory: () => T): T => {
hookCursor += 1;
return factory();
},
useRef: <T>(current: T) => {
const index = hookCursor;
hookCursor += 1;
if (!hookStates[index]) hookStates[index] = { current: null };
// SAFETY: this test hook preserves one mutable ref slot per hook index.
return hookStates[index] as { current: T };
},
memo: <T>(component: T): T => component,
};
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
const ref = props?.ref;
// SAFETY: the renderer test installs the typed fake document before JSX is
// evaluated; this branch only supplies its fake element factory.
const fakeDocument = activeFakeDocument;
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
const element = ref?.current ?? makeFakeElement(fakeDocument);
if (!ref?.current) {
element.childNodes.length = 0;
element.children.length = 0;
}
if (props) {
if (ref) ref.current = element;
if (props.className) element.setAttribute('class', props.className);
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
}
const jsxChildren = props?.children;
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
for (const child of allChildren) {
if (child) element.appendChild(child);
}
return element;
};
mock.module('react', () => ({ default: fakeReact }));
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => '',
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
}));
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({
getUrlScheme: () => null,
isAppLinkUrl: () => false,
isExternalHttpUrl: () => false,
openConfirmedAppLinkUrl: async () => false,
openExternalUrl: async () => undefined,
}));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
type RendererUiSelectorResult = boolean | string | (() => void);
const fakeUseUIStore = Object.assign(
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
{ getState: () => rendererUiState },
);
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
mock.module('./markdown/markdownCore', () => ({
getCachedMarkdownBlocks: () => cachedRendererBlocks,
renderMarkdownBlocks: async () => renderedRendererBlocks,
renderMarkdownSync: () => {
syncRenderCalls += 1;
return '<p>cold</p>';
},
}));
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
type TestDecorateContext = {
labels: { copy: string };
codeBlockLineWrap: boolean;
renderMermaid: (source: string) => { svg?: string };
};
mock.module('./markdown/decorate', () => ({
attachMarkdownInteractions: () => () => undefined,
applyMarkdownCodeBlockWrapState: () => undefined,
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
decorateCalls += 1;
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
root.setAttribute('data-test-decoration-marker', 'true');
root.setAttribute(
'data-test-decoration',
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
);
},
getMarkdownCodeText: () => '',
}));
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
mock.module('./markdown/mermaidViewer', () => ({
createMermaidViewerRegistry: () => {
mermaidRegistryCreates += 1;
return {
refresh: () => undefined,
cleanup: () => { mermaidRegistryCleanups += 1; },
};
},
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
const resetRendererTestState = () => {
cachedRendererBlocks = null;
renderedRendererBlocks = [];
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
hookCursor = 0;
hookStates = [];
layoutEffects.length = 0;
passiveEffects.length = 0;
currentContextVersion = 0;
rendererThemeIndex = 0;
rendererUiState.codeBlockLineWrap = false;
};
const beginRendererRender = () => {
hookCursor = 0;
return renderMarkdownForTest();
};
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
throw new Error('Renderer test did not return its fake JSX root');
}
// SAFETY: the structural check confirms this ReactNode is the object
// returned by the mocked JSX runtime.
const candidate = value as object;
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
return candidate as FakeElement;
};
const runRendererLayoutEffects = () => {
const pending = layoutEffects.splice(0);
for (const effect of pending) effect();
};
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
if (root.getAttribute('data-md-id') === id) return root;
for (const child of root.children) {
const match = findBlock(child, id);
if (match) return match;
}
return null;
};
const renderMarkdownForTest = () => MarkdownRenderer({
content: 'cached markdown',
messageId: 'message-1',
isAnimated: false,
isStreaming: false,
});
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
const restoreDom = installRendererDom();
const previousThemeIndex = rendererThemeIndex;
try {
await run();
} finally {
rendererThemeIndex = previousThemeIndex;
restoreDom();
}
};
describe('parseFileReference', () => {
test('returns null for empty or whitespace input', () => {
expect(parse('')).toBeNull();
@@ -72,11 +429,7 @@ describe('parseFileReference', () => {
});
test('preserves line:col form (does not interpret as range)', () => {
expect(parse('src/foo.ts:42:8')).toEqual({
path: 'src/foo.ts',
line: 42,
column: 8,
});
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
});
test('preserves hash form', () => {
@@ -110,3 +463,95 @@ describe('localPathFromFileUrl', () => {
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
});
});
describe('MarkdownRenderer warm settled path', () => {
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
renderedRendererBlocks = cachedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
// SAFETY: the test JSX adapter returns the fake element assigned to the
// renderer container ref and exposes the DOM members used below.
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(syncRenderCalls).toBe(0);
const block = findBlock(root, 'full:cached');
expect(block).not.toBeNull();
expect(block?.innerHTML).toBe('<p>cached</p>');
expect(block?.getAttribute('data-md-block')).toBe('');
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
expect(block?.style.display).toBe('contents');
expect(decorateCalls).toBe(1);
runRendererPassiveEffects();
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
return withRendererDom(() => {
resetRendererTestState();
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
renderedRendererBlocks = cachedRendererBlocks;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
morphCalls = 0;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(1);
const cleanups = runRendererPassiveEffects();
for (const cleanup of cleanups) cleanup?.();
expect(mermaidRegistryCleanups).toBe(1);
beginRendererRender();
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(2);
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
expect(morphCalls).toBe(0);
});
});
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{
id: 'full:context',
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
}];
renderedRendererBlocks = cachedRendererBlocks;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
const block = findBlock(root, 'full:context');
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
expect(firstDecorationId).not.toBeNull();
const firstDecorateCalls = decorateCalls;
rendererThemeIndex = 1;
currentContextVersion = 1;
rendererUiState.codeBlockLineWrap = true;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
await Promise.resolve();
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
expect(syncRenderCalls).toBe(0);
expect(morphCalls).toBe(0);
const updatedBlock = findBlock(root, 'full:context');
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
expect(mermaidRegistryCreates).toBeGreaterThan(1);
});
});
});
@@ -20,7 +20,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore';
import {
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync,
type MarkdownImageMode,
} from './markdown/markdownCore';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
@@ -659,6 +664,18 @@ const useMermaidInlineInteractions = ({
// so a stable diagram is laid out once and served from cache thereafter.
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
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 getMarkdownDecorationId = (ctx: DecorateContext): string => {
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
if (existing) return existing;
const id = `decoration-${nextMarkdownDecorationId}`;
nextMarkdownDecorationId += 1;
MARKDOWN_DECORATION_IDS.set(ctx, id);
return id;
};
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
const existing = MERMAID_RENDER_CACHE.get(key);
@@ -780,25 +797,40 @@ const useMorphdomMarkdown = ({
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
const decorationId = getMarkdownDecorationId(ctx);
if (text && target.childNodes.length === 0) {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
// `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text, imageMode);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
// <pre>/tables that "snap" into their decorated form a tick later. Matching
// the structure here keeps the async morph to syntax colors only.
decorateMarkdown(block, ctx);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) {
refreshMermaidViewers();
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
if (cachedBlocks) {
let hasMermaidBlock = false;
for (const cachedBlock of cachedBlocks) {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = cachedBlock.html;
decorateMarkdown(block, ctx);
block.setAttribute('data-md-id', cachedBlock.id);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
target.appendChild(block);
}
if (hasMermaidBlock) refreshMermaidViewers();
} else {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text, imageMode);
decorateMarkdown(block, ctx);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
}
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
// but the viewer registry does not, so recreate it without reinstalling
// or re-decorating ordinary blocks.
refreshMermaidViewers();
}
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -810,14 +842,12 @@ const useMorphdomMarkdown = ({
if (!container) return;
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message.
blocks.forEach((block, index) => {
let el = existing[index];
if (!el) {
@@ -826,7 +856,28 @@ const useMorphdomMarkdown = ({
el.style.display = 'contents';
target.appendChild(el);
}
if (el.getAttribute('data-md-id') === block.id) return;
if (el.getAttribute('data-md-id') === block.id) {
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
if (hasMermaidBlock) {
mermaidViewerRef.current?.cleanup();
mermaidViewerRef.current = null;
}
const replacement = document.createElement('div');
replacement.setAttribute('data-md-block', '');
replacement.style.display = 'contents';
replacement.innerHTML = block.html;
decorateMarkdown(replacement, ctx);
replacement.setAttribute('data-md-id', block.id);
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
el.replaceWith(replacement);
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
}
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
return;
}
const temp = document.createElement('div');
temp.innerHTML = block.html;
@@ -838,12 +889,12 @@ const useMorphdomMarkdown = ({
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
});
el.setAttribute('data-md-id', block.id);
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
});
// Remove any trailing block elements no longer present.
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
let removedMermaidBlock = false;
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -856,7 +907,6 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
});
return () => {
@@ -49,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync,
resetMarkdownHtmlCacheForTests,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
@@ -90,6 +93,47 @@ describe('markdown sanitization', () => {
});
describe('Markdown block cache reads', () => {
test('returns all settled blocks synchronously after a full cache hit', async () => {
resetMarkdownHtmlCacheForTests();
const text = '**cached** settled markdown';
expect(getCachedMarkdownBlocks(text)).toBeNull();
const rendered = await renderMarkdownBlocks(text, false);
expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
});
test('returns null for a cold or partial settled miss', async () => {
resetMarkdownHtmlCacheForTests();
const first = 'first settled block';
const changed = 'first settled block\n\nsecond settled block';
await renderMarkdownBlocks(first, false);
expect(getCachedMarkdownBlocks(changed)).toBeNull();
});
test('keeps image mode identity out of the settled full hit', async () => {
resetMarkdownHtmlCacheForTests();
const text = '![image](https://example.test/image.png)';
await renderMarkdownBlocks(text, false, 'inline');
expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
});
test('does not treat streaming live-cache entries as settled full hits', async () => {
resetMarkdownHtmlCacheForTests();
const text = 'streaming markdown';
await renderMarkdownBlocks(text, true);
expect(getCachedMarkdownBlocks(text)).toBeNull();
});
});
describe('Markdown images', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
@@ -548,10 +548,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size,
});
const parseBlock = async (
block: MarkdownBlock,
imageMode: MarkdownImageMode,
): Promise<string> => {
/**
* Read a settled render synchronously when every block is already in the full
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
* or expand either cache.
*/
export const getCachedMarkdownBlocks = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): RenderedBlock[] | null => {
if (!text) return [];
const blocks = streamBlocks(text, false);
const rendered: RenderedBlock[] = [];
for (const block of blocks) {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const html = fullBlockCache.get(id);
if (html === undefined) return null;
rendered.push({ id, html });
}
return rendered;
};
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);