perf(ui): reuse warm markdown blocks
This commit is contained in:
@@ -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';
|
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||||
|
|
||||||
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
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', () => {
|
describe('parseFileReference', () => {
|
||||||
test('returns null for empty or whitespace input', () => {
|
test('returns null for empty or whitespace input', () => {
|
||||||
expect(parse('')).toBeNull();
|
expect(parse('')).toBeNull();
|
||||||
@@ -72,11 +429,7 @@ describe('parseFileReference', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('preserves line:col form (does not interpret as range)', () => {
|
test('preserves line:col form (does not interpret as range)', () => {
|
||||||
expect(parse('src/foo.ts:42:8')).toEqual({
|
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
||||||
path: 'src/foo.ts',
|
|
||||||
line: 42,
|
|
||||||
column: 8,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves hash form', () => {
|
test('preserves hash form', () => {
|
||||||
@@ -110,3 +463,95 @@ describe('localPathFromFileUrl', () => {
|
|||||||
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
|
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 { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
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 { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||||
import {
|
import {
|
||||||
@@ -659,6 +664,18 @@ const useMermaidInlineInteractions = ({
|
|||||||
// so a stable diagram is laid out once and served from cache thereafter.
|
// 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 = new Map<string, MermaidRender>();
|
||||||
const MERMAID_RENDER_CACHE_MAX = 100;
|
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 cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
|
||||||
const existing = MERMAID_RENDER_CACHE.get(key);
|
const existing = MERMAID_RENDER_CACHE.get(key);
|
||||||
@@ -780,25 +797,40 @@ const useMorphdomMarkdown = ({
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
const decorationId = getMarkdownDecorationId(ctx);
|
||||||
if (text && target.childNodes.length === 0) {
|
if (text && target.childNodes.length === 0) {
|
||||||
const block = document.createElement('div');
|
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
|
||||||
block.setAttribute('data-md-block', '');
|
if (cachedBlocks) {
|
||||||
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
let hasMermaidBlock = false;
|
||||||
// HTML body — the wrapper exists only for per-block reconciliation.
|
for (const cachedBlock of cachedBlocks) {
|
||||||
block.style.display = 'contents';
|
const block = document.createElement('div');
|
||||||
block.innerHTML = renderMarkdownSync(text, imageMode);
|
block.setAttribute('data-md-block', '');
|
||||||
// Decorate synchronously too: wrap code blocks in their framed card,
|
block.style.display = 'contents';
|
||||||
// mark inline code, build table controls, etc. The async pass re-decorates
|
block.innerHTML = cachedBlock.html;
|
||||||
// its own DOM before morphing, so without this the first paint shows bare
|
decorateMarkdown(block, ctx);
|
||||||
// <pre>/tables that "snap" into their decorated form a tick later. Matching
|
block.setAttribute('data-md-id', cachedBlock.id);
|
||||||
// the structure here keeps the async morph to syntax colors only.
|
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||||
decorateMarkdown(block, ctx);
|
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
|
||||||
target.appendChild(block);
|
target.appendChild(block);
|
||||||
if (shouldRefreshMermaidViewers(block)) {
|
}
|
||||||
refreshMermaidViewers();
|
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(() => () => {
|
React.useEffect(() => () => {
|
||||||
mermaidViewerRef.current?.cleanup();
|
mermaidViewerRef.current?.cleanup();
|
||||||
@@ -810,14 +842,12 @@ 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 decorationId = getMarkdownDecorationId(ctx);
|
||||||
|
|
||||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const existing = Array.from(target.children) as HTMLElement[];
|
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) => {
|
blocks.forEach((block, index) => {
|
||||||
let el = existing[index];
|
let el = existing[index];
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -826,7 +856,28 @@ const useMorphdomMarkdown = ({
|
|||||||
el.style.display = 'contents';
|
el.style.display = 'contents';
|
||||||
target.appendChild(el);
|
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');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = block.html;
|
temp.innerHTML = block.html;
|
||||||
@@ -838,12 +889,12 @@ const useMorphdomMarkdown = ({
|
|||||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||||
});
|
});
|
||||||
el.setAttribute('data-md-id', block.id);
|
el.setAttribute('data-md-id', block.id);
|
||||||
|
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||||
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
||||||
refreshMermaidViewers();
|
refreshMermaidViewers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove any trailing block elements no longer present.
|
|
||||||
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
||||||
let removedMermaidBlock = false;
|
let removedMermaidBlock = false;
|
||||||
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
||||||
@@ -856,7 +907,6 @@ const useMorphdomMarkdown = ({
|
|||||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||||
refreshMermaidViewers();
|
refreshMermaidViewers();
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
|
|||||||
const {
|
const {
|
||||||
__markdownImageCandidateCacheForTests,
|
__markdownImageCandidateCacheForTests,
|
||||||
extractMarkdownImageCandidates,
|
extractMarkdownImageCandidates,
|
||||||
|
getCachedMarkdownBlocks,
|
||||||
|
renderMarkdownBlocks,
|
||||||
renderMarkdownSync,
|
renderMarkdownSync,
|
||||||
|
resetMarkdownHtmlCacheForTests,
|
||||||
} = await import('./markdownCore');
|
} = await import('./markdownCore');
|
||||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
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 = '';
|
||||||
|
|
||||||
|
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', () => {
|
describe('Markdown images', () => {
|
||||||
test('renders assistant images as icon-ready text without loading the source', () => {
|
test('renders assistant images as icon-ready text without loading the source', () => {
|
||||||
const html = renderMarkdownSync([
|
const html = renderMarkdownSync([
|
||||||
|
|||||||
@@ -548,10 +548,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
|
|||||||
live: liveBlockCache.size,
|
live: liveBlockCache.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parseBlock = async (
|
/**
|
||||||
block: MarkdownBlock,
|
* Read a settled render synchronously when every block is already in the full
|
||||||
imageMode: MarkdownImageMode,
|
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
|
||||||
): Promise<string> => {
|
* 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 parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||||
const withMath = renderMathExpressions(parsed);
|
const withMath = renderMathExpressions(parsed);
|
||||||
|
|||||||
Reference in New Issue
Block a user