From cd179bd1182bc972aaad200499eb83525b1a1c8c Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Sun, 23 Aug 2026 15:54:18 +0800 Subject: [PATCH] perf(ui): avoid parsing markdown control icons --- .../MarkdownRendererImpl.performance.test.tsx | 20 +++++++- .../src/components/chat/markdown/decorate.ts | 46 ++++++++++--------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx index 9794f7c3..e148277b 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx @@ -6,6 +6,7 @@ import type { TextPart } from '@opencode-ai/sdk/v2'; type OperationCounts = { innerHTMLWrites: number; + spriteIconInnerHTMLWrites: number; querySelectorAllCalls: number; appendCalls: number; replaceCalls: number; @@ -75,6 +76,7 @@ let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number }; const makeCounts = (): OperationCounts => ({ innerHTMLWrites: 0, + spriteIconInnerHTMLWrites: 0, querySelectorAllCalls: 0, appendCalls: 0, replaceCalls: 0, @@ -204,7 +206,10 @@ const initializePerformanceDom = async (): Promise => { configurable: true, get: innerHTMLDescriptor.get, 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); }, }); @@ -243,7 +248,7 @@ const initializePerformanceDom = async (): Promise => { } }); const svgSetAttribute = SVGElement.prototype.setAttribute; 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.geometrySequence.push('write'); } @@ -313,6 +318,17 @@ afterAll(() => { }); 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.'; diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 405616f8..eeb15fdd 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -43,28 +43,30 @@ export type DecorateContext = { onPreviewLoopback?: (url: string) => void; }; -// Reference the app's icon sprite (injected into 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-`. -const spriteIcon = (name: IconName): string => - ``; - 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; 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; }; @@ -196,11 +198,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); @@ -492,7 +494,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); } }