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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 17:14:15 +08:00
parent 5e899bb1a1
commit 2d7d11d822
2 changed files with 32 additions and 9 deletions

View File

@ -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$')

View File

@ -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<string, string>()
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<HTMLDivElement>) => {
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 (
<div
className={proseClasses}
dangerouslySetInnerHTML={{ __html: cleanHtml }}
dangerouslySetInnerHTML={{ __html: parts[0]?.type === 'html' ? parts[0].content : '' }}
onClick={handleClick}
/>
)
@ -419,7 +440,7 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
<div className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content, mathBlocks) }} />
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
) : shouldRenderAsMermaid(part.block) ? (
<MermaidRenderer key={part.block.id} code={part.block.code} />
) : (