Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -412,7 +412,7 @@ const ChatViewport = React.memo(({
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
/>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||
<PromptNavigatorRail
|
||||
turnIds={promptTurnIds}
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
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;
|
||||
spriteIconInnerHTMLWrites: number;
|
||||
querySelectorAllCalls: number;
|
||||
appendCalls: number;
|
||||
replaceCalls: number;
|
||||
removeCalls: number;
|
||||
getBoundingClientRectCalls: number;
|
||||
viewBoxWrites: number;
|
||||
resizeObserverCreates: number;
|
||||
resizeObserverObserveCalls: number;
|
||||
geometrySequence: Array<'read' | 'write'>;
|
||||
};
|
||||
|
||||
type FixtureMetrics = OperationCounts & {
|
||||
renderers: number;
|
||||
markdownBlocks: number;
|
||||
mermaidBlocks: number;
|
||||
mermaidRenderedCount: number;
|
||||
mermaidSvgCount: number;
|
||||
};
|
||||
|
||||
const fixture = [
|
||||
'# Synthetic mount fixture',
|
||||
'',
|
||||
'A paragraph with **bold text**, a table, and a stable link.',
|
||||
'',
|
||||
'| name | value |',
|
||||
'| --- | ---: |',
|
||||
'| alpha | 1 |',
|
||||
'| beta | 2 |',
|
||||
'',
|
||||
'```typescript',
|
||||
'const answer = 42;',
|
||||
'console.log(answer);',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[Start] --> B[Finish]',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph LR',
|
||||
' Client[Client] --> Server[Server]',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const fixtureWorkload = {
|
||||
rendererCount: 3,
|
||||
domBlocksPerRenderer: 1,
|
||||
mermaidBlocksPerRenderer: 2,
|
||||
};
|
||||
|
||||
let windowInstance: Window;
|
||||
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
|
||||
let activeCounts: OperationCounts | null = null;
|
||||
let animationFrameQueue: FrameRequestCallback[] = [];
|
||||
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
|
||||
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,
|
||||
spriteIconInnerHTMLWrites: 0,
|
||||
querySelectorAllCalls: 0,
|
||||
appendCalls: 0,
|
||||
replaceCalls: 0,
|
||||
removeCalls: 0,
|
||||
getBoundingClientRectCalls: 0,
|
||||
viewBoxWrites: 0,
|
||||
resizeObserverCreates: 0,
|
||||
resizeObserverObserveCalls: 0,
|
||||
geometrySequence: [],
|
||||
});
|
||||
|
||||
const installGlobal = (name: string, value: Window[keyof Window]): void => {
|
||||
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
|
||||
const waitForSettledEffects = async (): Promise<void> => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const flushAnimationFrame = async (): Promise<void> => {
|
||||
const callbacks = animationFrameQueue;
|
||||
animationFrameQueue = [];
|
||||
await act(async () => {
|
||||
for (const callback of callbacks) callback(windowInstance.performance.now());
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const flushDeferredMermaidInitialization = async (): Promise<void> => {
|
||||
await flushAnimationFrame();
|
||||
await flushAnimationFrame();
|
||||
};
|
||||
|
||||
const mountFixture = async (rendererCount: number): Promise<{
|
||||
root: Root;
|
||||
host: HTMLDivElement;
|
||||
operations: OperationCounts;
|
||||
counts: FixtureMetrics;
|
||||
}> => {
|
||||
const counts = makeCounts();
|
||||
activeCounts = counts;
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
{Array.from({ length: rendererCount }, (_, index) => (
|
||||
<MarkdownRenderer
|
||||
key={`fixture-${index}`}
|
||||
content={fixture}
|
||||
messageId={`fixture-message-${index}`}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => waitForSettledEffects());
|
||||
|
||||
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
|
||||
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
|
||||
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
|
||||
return {
|
||||
root,
|
||||
host,
|
||||
operations: counts,
|
||||
counts: {
|
||||
...counts,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks,
|
||||
mermaidRenderedCount,
|
||||
mermaidSvgCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
|
||||
const { root, host, operations } = await mountFixture(rendererCount);
|
||||
await flushDeferredMermaidInitialization();
|
||||
const counts: FixtureMetrics = {
|
||||
...operations,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
await act(async () => root.unmount());
|
||||
return counts;
|
||||
};
|
||||
|
||||
const initializePerformanceDom = async (): Promise<void> => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
|
||||
windowInstance.document.close();
|
||||
previousGlobals = new Map();
|
||||
installGlobal('window', windowInstance);
|
||||
installGlobal('document', windowInstance.document);
|
||||
installGlobal('navigator', windowInstance.navigator);
|
||||
installGlobal('customElements', windowInstance.customElements);
|
||||
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
|
||||
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
|
||||
const globalValue = windowInstance[name as keyof Window];
|
||||
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
|
||||
installGlobal(name, globalValue);
|
||||
}
|
||||
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
|
||||
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
|
||||
animationFrameQueue.push(callback);
|
||||
return animationFrameQueue.length;
|
||||
} });
|
||||
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const elementPrototype = Element.prototype;
|
||||
const nodePrototype = Node.prototype;
|
||||
const documentPrototype = Document.prototype;
|
||||
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
|
||||
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||
configurable: true,
|
||||
get: innerHTMLDescriptor.get,
|
||||
set(value: string) {
|
||||
if (activeCounts) {
|
||||
activeCounts.innerHTMLWrites += 1;
|
||||
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
|
||||
}
|
||||
innerHTMLDescriptor.set?.call(this, value);
|
||||
},
|
||||
});
|
||||
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
|
||||
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
|
||||
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalDocumentQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalAppendChild = nodePrototype.appendChild;
|
||||
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
|
||||
if (activeCounts) activeCounts.appendCalls += 1;
|
||||
return originalAppendChild.call(this, node);
|
||||
} });
|
||||
const originalReplaceWith = elementPrototype.replaceWith;
|
||||
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
|
||||
if (activeCounts) activeCounts.replaceCalls += 1;
|
||||
return originalReplaceWith.apply(this, nodes);
|
||||
} });
|
||||
const originalRemove = elementPrototype.remove;
|
||||
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
|
||||
if (activeCounts) activeCounts.removeCalls += 1;
|
||||
return originalRemove.call(this);
|
||||
} });
|
||||
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
|
||||
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
|
||||
if (activeCounts) {
|
||||
activeCounts.getBoundingClientRectCalls += 1;
|
||||
activeCounts.geometrySequence.push('read');
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
} });
|
||||
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
||||
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
||||
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
|
||||
activeCounts.viewBoxWrites += 1;
|
||||
activeCounts.geometrySequence.push('write');
|
||||
}
|
||||
return svgSetAttribute.call(this, name, value);
|
||||
} });
|
||||
class CountingResizeObserver {
|
||||
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
|
||||
if (activeCounts) activeCounts.resizeObserverCreates += 1;
|
||||
notifyResize = callback;
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
installGlobal('ResizeObserver', CountingResizeObserver);
|
||||
|
||||
const fakeState = {
|
||||
openContextPreview: () => undefined,
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
};
|
||||
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
|
||||
const { mock } = await import('bun:test');
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
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, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => 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: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
|
||||
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => 'diagram',
|
||||
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('./markdown/markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
highlightLinesInWorker: async () => null,
|
||||
highlightTokensInWorker: async () => null,
|
||||
}));
|
||||
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();
|
||||
|
||||
afterAll(() => {
|
||||
for (const [name, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
||||
test('builds Markdown sprite controls without parsing SVG markup', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
|
||||
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
|
||||
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
|
||||
await act(async () => mounted.root.unmount());
|
||||
|
||||
expect(spriteControlCount).toBeGreaterThan(0);
|
||||
expect(spriteIconInnerHTMLWrites).toBe(0);
|
||||
});
|
||||
|
||||
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('does not detach Markdown DOM that intersects the active selection', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = 'selected content';
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-selected"
|
||||
part={{
|
||||
id: 'part-selected',
|
||||
sessionID: 'session-selected',
|
||||
messageID: 'message-selected',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
|
||||
if (!markdown) throw new Error('Expected mounted Markdown content');
|
||||
const originalGetSelection = window.getSelection;
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
} finally {
|
||||
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
|
||||
clearDetachedMarkdownDomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
|
||||
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
||||
const critical = mounted.counts;
|
||||
|
||||
expect(critical.getBoundingClientRectCalls).toBe(0);
|
||||
expect(critical.viewBoxWrites).toBe(0);
|
||||
expect(critical.resizeObserverCreates).toBe(0);
|
||||
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
|
||||
|
||||
await flushDeferredMermaidInitialization();
|
||||
const metrics = {
|
||||
...mounted.operations,
|
||||
renderers: fixtureWorkload.rendererCount,
|
||||
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
|
||||
expect(metrics.renderers).toBe(3);
|
||||
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
|
||||
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
|
||||
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
|
||||
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
|
||||
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
|
||||
expect(metrics.appendCalls).toBeGreaterThan(0);
|
||||
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.resizeObserverCreates).toBe(1);
|
||||
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
|
||||
|
||||
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
|
||||
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
|
||||
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
|
||||
const writesBeforeResize = mounted.operations.viewBoxWrites;
|
||||
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
|
||||
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
|
||||
await act(async () => mounted.root.unmount());
|
||||
});
|
||||
|
||||
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
await act(async () => mounted.root.unmount());
|
||||
await flushDeferredMermaidInitialization();
|
||||
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(0);
|
||||
expect(mounted.operations.resizeObserverCreates).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
|
||||
const three = await runFixture(3);
|
||||
const six = await runFixture(6);
|
||||
|
||||
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
|
||||
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
|
||||
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
|
||||
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
|
||||
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
|
||||
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
|
||||
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
|
||||
expect(three.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,377 @@
|
||||
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 renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
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 => {
|
||||
void ownerDocument;
|
||||
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) => {
|
||||
void current;
|
||||
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: () => renderMarkdownBlocksForTest(),
|
||||
renderMarkdownSync: () => {
|
||||
syncRenderCalls += 1;
|
||||
return '<p>cold</p>';
|
||||
},
|
||||
}));
|
||||
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;
|
||||
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 = [];
|
||||
renderMarkdownBlocksForTest = async () => 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 +440,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 +474,120 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -45,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();
|
||||
@@ -684,6 +691,19 @@ 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 MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
||||
|
||||
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);
|
||||
@@ -768,6 +788,7 @@ const useMorphdomMarkdown = ({
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
@@ -775,12 +796,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) {
|
||||
@@ -796,6 +825,63 @@ 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 selection = window.getSelection();
|
||||
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) 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
|
||||
@@ -805,25 +891,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();
|
||||
@@ -835,9 +936,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[];
|
||||
|
||||
// Reconcile per block: only re-morph blocks whose content changed, leaving
|
||||
@@ -854,7 +957,28 @@ const useMorphdomMarkdown = ({
|
||||
target.appendChild(el);
|
||||
isNewBlock = true;
|
||||
}
|
||||
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;
|
||||
@@ -883,12 +1007,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) {
|
||||
@@ -901,13 +1025,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;
|
||||
@@ -988,6 +1114,33 @@ 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();
|
||||
// Memoized on scalar identities, not the part object: sync-store reducers
|
||||
// recreate part objects on unrelated updates, and an object-identity dep
|
||||
// re-ran the async render pipeline for identical content.
|
||||
const settledSessionID = settledPart?.sessionID;
|
||||
const settledMessageID = settledPart?.messageID;
|
||||
const settledPartID = settledPart?.id;
|
||||
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 || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
|
||||
// content.length is a cheap fingerprint: an edited or reverted part that
|
||||
// re-materializes under the same id must not restore the old DOM.
|
||||
return {
|
||||
scope: `${runtimeKey}\0${settledSessionID}`,
|
||||
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
|
||||
locale,
|
||||
directory: effectiveDirectory,
|
||||
};
|
||||
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
|
||||
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
@@ -995,9 +1148,10 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
||||
imageMode,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
|
||||
@@ -43,28 +43,30 @@ export type DecorateContext = {
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
};
|
||||
|
||||
// Reference the app's icon sprite (injected into <body> by the shared Icon
|
||||
// component) so DOM-built controls use the same themed icons as the rest of
|
||||
// the app. Sprite symbols are registered under `#oc-<name>`.
|
||||
const spriteIcon = (name: IconName): string =>
|
||||
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
|
||||
|
||||
const ICONS = {
|
||||
copy: spriteIcon('file-copy'),
|
||||
check: spriteIcon('check'),
|
||||
download: spriteIcon('download'),
|
||||
zoomIn: spriteIcon('add'),
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
image: spriteIcon('file-image'),
|
||||
} as const;
|
||||
copy: 'file-copy',
|
||||
check: 'check',
|
||||
download: 'download',
|
||||
zoomIn: 'add',
|
||||
zoomOut: 'subtract',
|
||||
fit: 'refresh',
|
||||
textWrap: 'text-wrap',
|
||||
image: 'file-image',
|
||||
} as const satisfies Record<string, IconName>;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
|
||||
|
||||
const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
|
||||
const iconName = ICONS[icon];
|
||||
const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'remixicon size-3.5');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', `#oc-${iconName}`);
|
||||
svg.appendChild(use);
|
||||
el.replaceChildren(svg);
|
||||
};
|
||||
|
||||
const decorateImageLabels = (root: HTMLElement): void => {
|
||||
@@ -74,7 +76,7 @@ const decorateImageLabels = (root: HTMLElement): void => {
|
||||
icon.className = 'inline-flex shrink-0';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||
setIconHtml(icon, ICONS.image);
|
||||
setIcon(icon, 'image');
|
||||
label.prepend(icon);
|
||||
}
|
||||
};
|
||||
@@ -86,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
|
||||
button.setAttribute('data-md-action', slot);
|
||||
button.setAttribute('title', title);
|
||||
button.setAttribute('aria-label', title);
|
||||
setIconHtml(button, ICONS[icon]);
|
||||
setIcon(button, icon);
|
||||
return button;
|
||||
};
|
||||
|
||||
@@ -199,11 +201,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
|
||||
};
|
||||
|
||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
||||
setIconHtml(button, ICONS.check);
|
||||
setIcon(button, 'check');
|
||||
button.setAttribute('title', copiedTitle);
|
||||
button.setAttribute('aria-label', copiedTitle);
|
||||
window.setTimeout(() => {
|
||||
setIconHtml(button, ICONS[restore]);
|
||||
setIcon(button, restore);
|
||||
button.setAttribute('title', restoreTitle);
|
||||
button.setAttribute('aria-label', restoreTitle);
|
||||
}, 2000);
|
||||
@@ -503,7 +505,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
preview.setAttribute('data-md-url', href);
|
||||
preview.setAttribute('title', ctx.labels.previewTitle);
|
||||
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
||||
setIconHtml(preview, ICONS.download);
|
||||
setIcon(preview, 'download');
|
||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
@@ -565,7 +567,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,104 @@
|
||||
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, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
|
||||
|
||||
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',
|
||||
directory: '/repo-a',
|
||||
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',
|
||||
directory: '/repo-a',
|
||||
})).toBeNull();
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-c',
|
||||
id: 'message-5:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).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('does not restore file-link DOM under another directory', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session', 'message', 'part');
|
||||
|
||||
cache.store(entry);
|
||||
|
||||
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
|
||||
});
|
||||
|
||||
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,124 @@
|
||||
export type DetachedMarkdownDomKey = {
|
||||
scope: string;
|
||||
id: string;
|
||||
locale: string;
|
||||
directory: 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 = {
|
||||
// Eight buckets cover a broader recent-session working set without
|
||||
// coupling eviction to React commit or microtask timing.
|
||||
maxSessions: 8,
|
||||
maxEntriesPerSession: 4,
|
||||
};
|
||||
|
||||
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 mismatched probe (different locale or directory for the same part)
|
||||
// must not destroy the entry — the matching renderer may still come for
|
||||
// it. Only a real hit transfers ownership out of the cache.
|
||||
if (entry.locale !== key.locale || entry.directory !== key.directory) 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);
|
||||
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();
|
||||
@@ -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 = '';
|
||||
|
||||
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([
|
||||
|
||||
@@ -557,10 +557,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);
|
||||
|
||||
@@ -22,6 +22,18 @@ type MermaidViewerController = {
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type InternalMermaidViewerController = MermaidViewerController & {
|
||||
viewport: HTMLElement;
|
||||
fitToViewport: (viewport: MermaidViewport) => void;
|
||||
};
|
||||
|
||||
type MermaidViewerRegistryState = {
|
||||
container: HTMLElement;
|
||||
controllers: Map<HTMLElement, InternalMermaidViewerController>;
|
||||
signatures: Map<HTMLElement, string>;
|
||||
disposed: boolean;
|
||||
};
|
||||
|
||||
type MermaidSvgBoundsSource = {
|
||||
viewBox?: string | null;
|
||||
width?: string | number | null;
|
||||
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
|
||||
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
|
||||
|
||||
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return isPositiveFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (value === null || value === undefined) return null;
|
||||
const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
|
||||
};
|
||||
|
||||
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
|
||||
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
|
||||
);
|
||||
const controllerByViewport = new WeakMap<HTMLElement, InternalMermaidViewerController>();
|
||||
const activeControllers = new Set<InternalMermaidViewerController>();
|
||||
const pendingRegistries = new Set<MermaidViewerRegistryState>();
|
||||
// Controllers are non-essential for the static SVG. Initialize all renderers
|
||||
// from one post-presentation batch so geometry reads precede every SVG write.
|
||||
let sharedResizeObserver: ResizeObserver | null = null;
|
||||
let pendingRegistryFlushFrame: number | null = null;
|
||||
let pendingResizeFrame: number | null = null;
|
||||
|
||||
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
||||
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
||||
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
|
||||
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
||||
);
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
|
||||
const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
|
||||
const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
|
||||
controllers.forEach((controller, index) => {
|
||||
const viewport = viewportSizes[index];
|
||||
if (viewport) controller.fitToViewport(viewport);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleActiveControllerFit = (): void => {
|
||||
if (pendingResizeFrame !== null || activeControllers.size === 0) return;
|
||||
pendingResizeFrame = window.requestAnimationFrame(() => {
|
||||
pendingResizeFrame = null;
|
||||
fitControllers(Array.from(activeControllers));
|
||||
});
|
||||
};
|
||||
|
||||
const ensureSharedResizeObserver = (): ResizeObserver | null => {
|
||||
if (sharedResizeObserver) return sharedResizeObserver;
|
||||
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||||
if (!ResizeObserverConstructor) return null;
|
||||
sharedResizeObserver = new ResizeObserverConstructor((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue;
|
||||
controllerByViewport.get(entry.target)?.fitToViewport({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
return sharedResizeObserver;
|
||||
};
|
||||
|
||||
const registerController = (controller: InternalMermaidViewerController): void => {
|
||||
if (activeControllers.has(controller)) return;
|
||||
const wasEmpty = activeControllers.size === 0;
|
||||
activeControllers.add(controller);
|
||||
controllerByViewport.set(controller.viewport, controller);
|
||||
ensureSharedResizeObserver()?.observe(controller.viewport);
|
||||
if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
|
||||
};
|
||||
|
||||
const unregisterController = (controller: InternalMermaidViewerController): void => {
|
||||
if (!activeControllers.delete(controller)) return;
|
||||
sharedResizeObserver?.unobserve(controller.viewport);
|
||||
controllerByViewport.delete(controller.viewport);
|
||||
if (activeControllers.size > 0) return;
|
||||
sharedResizeObserver?.disconnect();
|
||||
sharedResizeObserver = null;
|
||||
window.removeEventListener('resize', scheduleActiveControllerFit);
|
||||
if (pendingResizeFrame !== null) {
|
||||
window.cancelAnimationFrame(pendingResizeFrame);
|
||||
pendingResizeFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
|
||||
const viewport = getSvgViewport(block);
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
if (!viewport || !svg) {
|
||||
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
svg.removeAttribute('height');
|
||||
};
|
||||
|
||||
const fitToViewport = (size: MermaidViewport): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, size));
|
||||
};
|
||||
|
||||
const fit = (): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
|
||||
fitToViewport(getViewportSize(viewport));
|
||||
};
|
||||
|
||||
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
||||
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = (): void => {
|
||||
fit();
|
||||
};
|
||||
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||||
viewport.addEventListener('pointerdown', onPointerDown);
|
||||
viewport.addEventListener('pointermove', onPointerMove);
|
||||
viewport.addEventListener('pointerup', stopPan);
|
||||
viewport.addEventListener('pointercancel', stopPan);
|
||||
window.addEventListener('resize', onResize);
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
||||
observer?.observe(viewport);
|
||||
fit();
|
||||
|
||||
return {
|
||||
const controller: InternalMermaidViewerController = {
|
||||
viewport,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fit,
|
||||
fitToViewport,
|
||||
cleanup: () => {
|
||||
unregisterController(controller);
|
||||
viewport.removeEventListener('wheel', onWheel);
|
||||
viewport.removeEventListener('pointerdown', onPointerDown);
|
||||
viewport.removeEventListener('pointermove', onPointerMove);
|
||||
viewport.removeEventListener('pointerup', stopPan);
|
||||
viewport.removeEventListener('pointercancel', stopPan);
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer?.disconnect();
|
||||
if (clearClickSuppressionTimer !== null) {
|
||||
window.clearTimeout(clearClickSuppressionTimer);
|
||||
}
|
||||
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
controllerByBlock.delete(block);
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
|
||||
const controllers = new Map<HTMLElement, MermaidViewerController>();
|
||||
const signatures = new Map<HTMLElement, string>();
|
||||
|
||||
const refresh = (): void => {
|
||||
for (const [block, controller] of Array.from(controllers.entries())) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!container.contains(block) || signature !== signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
controllers.delete(block);
|
||||
signatures.delete(block);
|
||||
}
|
||||
const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
|
||||
for (const [block, controller] of state.controllers) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
state.controllers.delete(block);
|
||||
state.signatures.delete(block);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
|
||||
continue;
|
||||
}
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) {
|
||||
continue;
|
||||
}
|
||||
controllers.set(block, controller);
|
||||
signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
}
|
||||
const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
|
||||
if (state.disposed) return [];
|
||||
const newControllers: InternalMermaidViewerController[] = [];
|
||||
for (const block of Array.from(state.container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) continue;
|
||||
state.controllers.set(block, controller);
|
||||
state.signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
newControllers.push(controller);
|
||||
}
|
||||
return newControllers;
|
||||
};
|
||||
|
||||
const flushPendingRegistries = (): void => {
|
||||
const registries = Array.from(pendingRegistries);
|
||||
pendingRegistries.clear();
|
||||
const newControllers: InternalMermaidViewerController[] = [];
|
||||
for (const state of registries) {
|
||||
if (state.disposed) continue;
|
||||
removeStaleControllers(state);
|
||||
newControllers.push(...collectNewControllers(state));
|
||||
}
|
||||
fitControllers(newControllers);
|
||||
for (const controller of newControllers) registerController(controller);
|
||||
};
|
||||
|
||||
const schedulePendingRegistryFlush = (): void => {
|
||||
if (pendingRegistryFlushFrame !== null) return;
|
||||
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||
pendingRegistryFlushFrame = null;
|
||||
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||
pendingRegistryFlushFrame = null;
|
||||
flushPendingRegistries();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
|
||||
if (state.disposed) return;
|
||||
removeStaleControllers(state);
|
||||
pendingRegistries.add(state);
|
||||
schedulePendingRegistryFlush();
|
||||
};
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
|
||||
if (!(block instanceof HTMLElement)) return null;
|
||||
const existing = controllerByBlock.get(block);
|
||||
if (existing) return existing;
|
||||
|
||||
for (const state of pendingRegistries) {
|
||||
if (!state.container.contains(block)) continue;
|
||||
flushPendingRegistries();
|
||||
return controllerByBlock.get(block) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement) => {
|
||||
const state: MermaidViewerRegistryState = {
|
||||
container,
|
||||
controllers: new Map(),
|
||||
signatures: new Map(),
|
||||
disposed: false,
|
||||
};
|
||||
|
||||
const refresh = (): void => scheduleRegistryRefresh(state);
|
||||
|
||||
const cleanup = (): void => {
|
||||
for (const controller of controllers.values()) {
|
||||
state.disposed = true;
|
||||
pendingRegistries.delete(state);
|
||||
for (const controller of state.controllers.values()) {
|
||||
controller.cleanup();
|
||||
}
|
||||
controllers.clear();
|
||||
signatures.clear();
|
||||
state.controllers.clear();
|
||||
state.signatures.clear();
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
Reference in New Issue
Block a user