perf(ui): avoid parsing markdown control icons
This commit is contained in:
@@ -6,6 +6,7 @@ import type { TextPart } from '@opencode-ai/sdk/v2';
|
|||||||
|
|
||||||
type OperationCounts = {
|
type OperationCounts = {
|
||||||
innerHTMLWrites: number;
|
innerHTMLWrites: number;
|
||||||
|
spriteIconInnerHTMLWrites: number;
|
||||||
querySelectorAllCalls: number;
|
querySelectorAllCalls: number;
|
||||||
appendCalls: number;
|
appendCalls: number;
|
||||||
replaceCalls: number;
|
replaceCalls: number;
|
||||||
@@ -75,6 +76,7 @@ let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
|
|||||||
|
|
||||||
const makeCounts = (): OperationCounts => ({
|
const makeCounts = (): OperationCounts => ({
|
||||||
innerHTMLWrites: 0,
|
innerHTMLWrites: 0,
|
||||||
|
spriteIconInnerHTMLWrites: 0,
|
||||||
querySelectorAllCalls: 0,
|
querySelectorAllCalls: 0,
|
||||||
appendCalls: 0,
|
appendCalls: 0,
|
||||||
replaceCalls: 0,
|
replaceCalls: 0,
|
||||||
@@ -204,7 +206,10 @@ const initializePerformanceDom = async (): Promise<void> => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
get: innerHTMLDescriptor.get,
|
get: innerHTMLDescriptor.get,
|
||||||
set(value: string) {
|
set(value: string) {
|
||||||
if (activeCounts) activeCounts.innerHTMLWrites += 1;
|
if (activeCounts) {
|
||||||
|
activeCounts.innerHTMLWrites += 1;
|
||||||
|
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
|
||||||
|
}
|
||||||
innerHTMLDescriptor.set?.call(this, value);
|
innerHTMLDescriptor.set?.call(this, value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -243,7 +248,7 @@ const initializePerformanceDom = async (): Promise<void> => {
|
|||||||
} });
|
} });
|
||||||
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
||||||
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
||||||
if (name === 'viewBox' && activeCounts) {
|
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
|
||||||
activeCounts.viewBoxWrites += 1;
|
activeCounts.viewBoxWrites += 1;
|
||||||
activeCounts.geometrySequence.push('write');
|
activeCounts.geometrySequence.push('write');
|
||||||
}
|
}
|
||||||
@@ -313,6 +318,17 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
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 () => {
|
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
|
||||||
clearDetachedMarkdownDomCache();
|
clearDetachedMarkdownDomCache();
|
||||||
const content = '# Cached viewport\n\nA settled paragraph.';
|
const content = '# Cached viewport\n\nA settled paragraph.';
|
||||||
|
|||||||
@@ -43,28 +43,30 @@ export type DecorateContext = {
|
|||||||
onPreviewLoopback?: (url: string) => void;
|
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 = {
|
const ICONS = {
|
||||||
copy: spriteIcon('file-copy'),
|
copy: 'file-copy',
|
||||||
check: spriteIcon('check'),
|
check: 'check',
|
||||||
download: spriteIcon('download'),
|
download: 'download',
|
||||||
zoomIn: spriteIcon('add'),
|
zoomIn: 'add',
|
||||||
zoomOut: spriteIcon('subtract'),
|
zoomOut: 'subtract',
|
||||||
fit: spriteIcon('refresh'),
|
fit: 'refresh',
|
||||||
textWrap: spriteIcon('text-wrap'),
|
textWrap: 'text-wrap',
|
||||||
image: spriteIcon('file-image'),
|
image: 'file-image',
|
||||||
} as const;
|
} as const satisfies Record<string, IconName>;
|
||||||
|
|
||||||
const ICON_BTN_CLASS =
|
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)]';
|
'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 => {
|
const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
|
||||||
el.innerHTML = html;
|
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 => {
|
const decorateImageLabels = (root: HTMLElement): void => {
|
||||||
@@ -74,7 +76,7 @@ const decorateImageLabels = (root: HTMLElement): void => {
|
|||||||
icon.className = 'inline-flex shrink-0';
|
icon.className = 'inline-flex shrink-0';
|
||||||
icon.setAttribute('aria-hidden', 'true');
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||||
setIconHtml(icon, ICONS.image);
|
setIcon(icon, 'image');
|
||||||
label.prepend(icon);
|
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('data-md-action', slot);
|
||||||
button.setAttribute('title', title);
|
button.setAttribute('title', title);
|
||||||
button.setAttribute('aria-label', title);
|
button.setAttribute('aria-label', title);
|
||||||
setIconHtml(button, ICONS[icon]);
|
setIcon(button, icon);
|
||||||
return button;
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,11 +198,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
|
|||||||
};
|
};
|
||||||
|
|
||||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
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('title', copiedTitle);
|
||||||
button.setAttribute('aria-label', copiedTitle);
|
button.setAttribute('aria-label', copiedTitle);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setIconHtml(button, ICONS[restore]);
|
setIcon(button, restore);
|
||||||
button.setAttribute('title', restoreTitle);
|
button.setAttribute('title', restoreTitle);
|
||||||
button.setAttribute('aria-label', restoreTitle);
|
button.setAttribute('aria-label', restoreTitle);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -492,7 +494,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
|||||||
preview.setAttribute('data-md-url', href);
|
preview.setAttribute('data-md-url', href);
|
||||||
preview.setAttribute('title', ctx.labels.previewTitle);
|
preview.setAttribute('title', ctx.labels.previewTitle);
|
||||||
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
||||||
setIconHtml(preview, ICONS.download);
|
setIcon(preview, 'download');
|
||||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user