From 2d7d11d82293f4ce58ef7f9acd61e14b83d3dbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 17:14:15 +0800 Subject: [PATCH] Reduce formula-heavy chat render cost Formula-heavy conversations were still expensive after removing the fixed-height virtual chat window because every render repeated KaTeX expansion and HTML post-processing. Keep readable math, but reduce the DOM and CPU cost by rendering KaTeX without the hidden MathML copy, caching repeated formula output, and memoizing enhanced HTML parts outside the JSX render path. Constraint: H5 streaming scroll follow behavior must remain unchanged Rejected: Restore fixed-height virtualization | it caused blank gaps for variable-height math and code messages Rejected: Disable LaTeX rendering | formula readability is a user-facing feature Confidence: medium Scope-risk: narrow Directive: Do not reintroduce estimated-height chat virtualization without measured row heights for math-heavy messages Tested: cd desktop && bun run test -- MarkdownRenderer.test.tsx MessageList.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: git diff --check Not-tested: Manual desktop scroll trace on the user's formula-heavy production conversation --- .../markdown/MarkdownRenderer.test.tsx | 2 + .../components/markdown/MarkdownRenderer.tsx | 39 ++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index 5daca8a1..1fe526bb 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -122,6 +122,8 @@ describe('MarkdownRenderer', () => { ) expect(container.querySelectorAll('.katex')).toHaveLength(2) + expect(container.querySelectorAll('.katex-html')).toHaveLength(2) + expect(container.querySelector('.katex-mathml')).not.toBeInTheDocument() expect(container.querySelector('.md-math-inline')).toBeInTheDocument() expect(container.querySelector('.md-math-display')).toBeInTheDocument() expect(container.textContent).not.toContain('$E = mc^2$') diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index 8916b619..79a82a1b 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -27,10 +27,16 @@ type MathBlock = { displayMode: boolean } +type HtmlPart = { type: 'html'; content: string } +type CodePart = { type: 'code'; block: CodeBlock } +type MarkdownPart = HtmlPart | CodePart + const MERMAID_LANGUAGE = 'mermaid' const PLAINTEXT_LANGUAGES = new Set(['', 'text', 'plaintext', 'plain']) const MERMAID_DIAGRAM_START = /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|requirementDiagram|quadrantChart|xychart-beta|sankey-beta|block-beta|packet-beta|architecture|kanban)\b/i const CODE_FENCE_START = /^ {0,3}(`{3,}|~{3,})/ +const MATH_RENDER_CACHE_LIMIT = 200 +const mathRenderCache = new Map() function normalizeCodeLanguage(language: string | undefined): string | undefined { const normalized = language?.trim().split(/\s+/)[0]?.toLowerCase() @@ -221,13 +227,24 @@ function extractMath(content: string): { markdown: string; mathBlocks: MathBlock } function renderMath(block: MathBlock): string { + const cacheKey = `${block.displayMode ? 'block' : 'inline'}\0${block.tex}` + const cached = mathRenderCache.get(cacheKey) + if (cached) return cached + try { - return katex.renderToString(block.tex, { + const rendered = katex.renderToString(block.tex, { displayMode: block.displayMode, + output: 'html', throwOnError: false, strict: false, trust: false, }) + mathRenderCache.set(cacheKey, rendered) + if (mathRenderCache.size > MATH_RENDER_CACHE_LIMIT) { + const firstKey = mathRenderCache.keys().next().value + if (firstKey) mathRenderCache.delete(firstKey) + } + return rendered } catch { return DOMPurify.sanitize(block.tex) } @@ -239,6 +256,11 @@ function enhanceMarkdownHtml(html: string, mathBlocks: MathBlock[]): string { ADD_ATTR: ['xlink:href'], }) + const needsDomEnhancement = mathBlocks.length > 0 || /<(?:a|table)\b/i.test(cleanHtml) + if (!needsDomEnhancement) { + return cleanHtml + } + if (typeof document === 'undefined') { return cleanHtml } @@ -350,10 +372,10 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi const parts = useMemo(() => { if (codeBlocks.length === 0) { - return [{ type: 'html' as const, content: html }] + return [{ type: 'html' as const, content: enhanceMarkdownHtml(html, mathBlocks) }] } - const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = [] + const result: MarkdownPart[] = [] let remaining = html for (const block of codeBlocks) { @@ -363,18 +385,18 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi const before = remaining.slice(0, idx) if (before) { - result.push({ type: 'html', content: before }) + result.push({ type: 'html', content: enhanceMarkdownHtml(before, mathBlocks) }) } result.push({ type: 'code', block }) remaining = remaining.slice(idx + marker.length) } if (remaining) { - result.push({ type: 'html', content: remaining }) + result.push({ type: 'html', content: enhanceMarkdownHtml(remaining, mathBlocks) }) } return result - }, [html, codeBlocks]) + }, [html, codeBlocks, mathBlocks]) const handleClick = useCallback(async (event: ReactMouseEvent) => { const target = event.target as HTMLElement | null @@ -405,11 +427,10 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi }, [onLinkClick]) if (codeBlocks.length === 0) { - const cleanHtml = enhanceMarkdownHtml(html, mathBlocks) return (
) @@ -419,7 +440,7 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
{parts.map((part, i) => part.type === 'html' ? ( -
+
) : shouldRenderAsMermaid(part.block) ? ( ) : (