perf(desktop): rework markdown cache and paint off-window spacers via content-visibility

Two issues remained on long transcripts. (1) The markdown parse cache was 48
entries / 1.8MB and keyed by full content string, so a 2.6MB session thrashed
constantly and every streaming chunk evicted finalized history entries because
each delta produced a new key. (2) The virtualization spacers above and below
the active window were single huge divs, leaving the WebView nothing to paint
during reconciliation and producing the literal blank gaps users were seeing.

The markdown cache now keys on `${len}:${fnv1a}` and splits into a 200-entry /
8MB finalized cache plus a small 4-entry streaming cache that cannot evict
finalized parses. AssistantMessage forwards a `streaming` flag (and stops
disabling caching entirely while streaming, so revisiting a settled turn no
longer reparses). The spacers are now broken into ~800px chunks via a
VirtualSpacer component; each chunk uses `content-visibility: auto` with a
`contain-intrinsic-size` pinned to its real height, which is the combination
that lets the WebView paint placeholder boxes even when off-screen paint is
deferred — without restoring the regression the previous content-visibility
rollout caused on in-window items.

Tested: 726/726 desktop vitest suites pass, including 3 new markdown-cache cases
and 1 new spacer-chunk + in-window-no-content-visibility regression guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 23:45:29 +08:00
parent 659c63e5ba
commit b8645b0122
5 changed files with 213 additions and 37 deletions

View File

@ -28,7 +28,11 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} cache={!isStreaming} />
<MarkdownRenderer
content={content}
variant={documentLayout ? 'document' : 'default'}
streaming={isStreaming}
/>
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />

View File

@ -643,6 +643,49 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
})
it('splits large virtualization spacers into content-visibility chunks', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: Array.from({ length: 240 }, (_, index) => ({
id: `assistant-${index}`,
type: 'assistant_text',
content: `assistant transcript line ${index}`,
timestamp: index,
})),
}),
},
})
const { container } = render(<MessageList />)
const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 240 * 200 })
await waitForProgrammaticScrollReset()
// Scroll to middle so both top and bottom spacers are present
scrollArea.scrollTop = 20_000
await act(async () => {
fireEvent.scroll(scrollArea)
})
const topChunks = container.querySelectorAll('[data-virtual-spacer-chunk="top"]')
const bottomChunks = container.querySelectorAll('[data-virtual-spacer-chunk="bottom"]')
expect(topChunks.length).toBeGreaterThan(1)
expect(bottomChunks.length).toBeGreaterThan(1)
const firstTopChunk = topChunks[0] as HTMLElement
expect(firstTopChunk.style.contentVisibility).toBe('auto')
expect(firstTopChunk.style.containIntrinsicSize).toMatch(/^0 \d+px$/)
// Items inside the active window must NOT carry content-visibility (this
// is the regression guard that previous content-visibility rollout hit).
const visibleItems = container.querySelectorAll('[data-virtual-message-item]')
for (const item of visibleItems) {
expect((item as HTMLElement).style.contentVisibility).toBe('')
}
})
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
useChatStore.setState({
sessions: {

View File

@ -1122,6 +1122,49 @@ function buildVirtualTranscriptWindow(
}
}
const VIRTUAL_SPACER_CHUNK_PX = 800
function VirtualSpacer({ height, position }: { height: number; position: 'top' | 'bottom' }) {
if (height <= 0) return null
if (height <= VIRTUAL_SPACER_CHUNK_PX) {
return (
<div
data-virtual-spacer={position}
aria-hidden="true"
style={{ height }}
/>
)
}
// Splitting the spacer into chunks lets the WebView keep painting placeholder
// boxes via content-visibility:auto + contain-intrinsic-size, instead of
// leaving a single huge area unpainted while React reconciles the window.
const chunkCount = Math.max(1, Math.ceil(height / VIRTUAL_SPACER_CHUNK_PX))
const chunkHeight = Math.floor(height / chunkCount)
const remainder = height - chunkHeight * chunkCount
const chunks: Array<{ key: string; px: number }> = []
for (let i = 0; i < chunkCount; i++) {
const px = i === chunkCount - 1 ? chunkHeight + remainder : chunkHeight
chunks.push({ key: `${position}-${i}`, px })
}
return (
<div data-virtual-spacer={position} aria-hidden="true">
{chunks.map((chunk) => (
<div
key={chunk.key}
data-virtual-spacer-chunk={position}
style={{
height: chunk.px,
contentVisibility: 'auto',
containIntrinsicSize: `0 ${chunk.px}px`,
}}
/>
))}
</div>
)
}
const MeasuredRenderItem = memo(function MeasuredRenderItem({
itemKey,
onHeightChange,
@ -1726,12 +1769,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
>
{virtualTranscriptWindow.enabled && virtualTranscriptWindow.beforeHeight > 0 ? (
<div
data-virtual-spacer="top"
aria-hidden="true"
style={{ height: virtualTranscriptWindow.beforeHeight }}
/>
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.beforeHeight} position="top" />
) : null}
{virtualTranscriptWindow.items.map(({ item, index }) => {
@ -1753,12 +1792,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)
})}
{virtualTranscriptWindow.enabled && virtualTranscriptWindow.afterHeight > 0 ? (
<div
data-virtual-spacer="bottom"
aria-hidden="true"
style={{ height: virtualTranscriptWindow.afterHeight }}
/>
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.afterHeight} position="bottom" />
) : null}
{streamingText.trim() && (

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
@ -16,7 +16,7 @@ vi.mock('../chat/MermaidRenderer', () => ({
),
}))
import { MarkdownRenderer } from './MarkdownRenderer'
import { MarkdownRenderer, __markdownParseCacheInternals } from './MarkdownRenderer'
function visibleMathText(container: HTMLElement): string {
const clone = container.cloneNode(true) as HTMLElement
@ -288,3 +288,42 @@ describe('MarkdownRenderer', () => {
}
})
})
describe('MarkdownRenderer parse cache', () => {
beforeEach(() => {
__markdownParseCacheInternals.reset()
})
it('uses the finalized cache for non-streaming content and hits on the second render', () => {
const content = '# heading one\n\nbody text body text'
render(<MarkdownRenderer content={content} />)
expect(__markdownParseCacheInternals.hasFinalized(content)).toBe(true)
expect(__markdownParseCacheInternals.finalizedSize()).toBe(1)
const beforeChars = __markdownParseCacheInternals.finalizedChars()
render(<MarkdownRenderer content={content} />)
expect(__markdownParseCacheInternals.finalizedSize()).toBe(1)
expect(__markdownParseCacheInternals.finalizedChars()).toBe(beforeChars)
})
it('routes streaming content into the streaming cache without evicting finalized entries', () => {
const finalizedContent = 'finalized assistant turn text'
render(<MarkdownRenderer content={finalizedContent} />)
expect(__markdownParseCacheInternals.hasFinalized(finalizedContent)).toBe(true)
for (let i = 0; i < 8; i++) {
const chunk = `streaming partial ${i.toString().repeat(20)}`
render(<MarkdownRenderer content={chunk} streaming />)
}
expect(__markdownParseCacheInternals.hasFinalized(finalizedContent)).toBe(true)
expect(__markdownParseCacheInternals.streamingSize()).toBeLessThanOrEqual(4)
})
it('caps the finalized cache to roughly 200 entries', () => {
for (let i = 0; i < 220; i++) {
render(<MarkdownRenderer content={`entry ${i} content body`} />)
}
expect(__markdownParseCacheInternals.finalizedSize()).toBeLessThanOrEqual(200)
})
})

View File

@ -13,6 +13,7 @@ type Props = {
variant?: 'default' | 'document' | 'compact'
className?: string
cache?: boolean
streaming?: boolean
onLinkClick?: (href: string, event: ReactMouseEvent<HTMLDivElement>) => boolean | void
}
@ -307,36 +308,90 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
type MarkdownParseResult = ReturnType<typeof parseMarkdown>
const MARKDOWN_PARSE_CACHE_MAX_ENTRIES = 48
const MARKDOWN_PARSE_CACHE_MAX_CHARS = 1_800_000
const markdownParseCache = new Map<string, MarkdownParseResult>()
let markdownParseCacheChars = 0
type CacheEntry = {
parsed: MarkdownParseResult
chars: number
}
function getCachedMarkdownParse(content: string): MarkdownParseResult {
const cached = markdownParseCache.get(content)
const FINALIZED_CACHE_MAX_ENTRIES = 200
const FINALIZED_CACHE_MAX_CHARS = 8_000_000
const STREAMING_CACHE_MAX_ENTRIES = 4
const finalizedMarkdownCache = new Map<string, CacheEntry>()
const streamingMarkdownCache = new Map<string, CacheEntry>()
let finalizedMarkdownCacheChars = 0
function fnv1aHash(value: string): number {
let hash = 2166136261 >>> 0
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
function buildMarkdownCacheKey(content: string): string {
return `${content.length}:${fnv1aHash(content).toString(36)}`
}
function evictFinalizedMarkdownEntries(): void {
while (
finalizedMarkdownCache.size > FINALIZED_CACHE_MAX_ENTRIES ||
finalizedMarkdownCacheChars > FINALIZED_CACHE_MAX_CHARS
) {
const oldestKey = finalizedMarkdownCache.keys().next().value
if (typeof oldestKey !== 'string') break
const entry = finalizedMarkdownCache.get(oldestKey)
finalizedMarkdownCache.delete(oldestKey)
if (entry) finalizedMarkdownCacheChars -= entry.chars
}
}
function evictStreamingMarkdownEntries(): void {
while (streamingMarkdownCache.size > STREAMING_CACHE_MAX_ENTRIES) {
const oldestKey = streamingMarkdownCache.keys().next().value
if (typeof oldestKey !== 'string') break
streamingMarkdownCache.delete(oldestKey)
}
}
function getCachedMarkdownParse(content: string, streaming: boolean): MarkdownParseResult {
const cache = streaming ? streamingMarkdownCache : finalizedMarkdownCache
const key = buildMarkdownCacheKey(content)
const cached = cache.get(key)
if (cached) {
markdownParseCache.delete(content)
markdownParseCache.set(content, cached)
return cached
cache.delete(key)
cache.set(key, cached)
return cached.parsed
}
const parsed = parseMarkdown(content)
markdownParseCache.set(content, parsed)
markdownParseCacheChars += content.length
const entry: CacheEntry = { parsed, chars: content.length }
cache.set(key, entry)
while (
markdownParseCache.size > MARKDOWN_PARSE_CACHE_MAX_ENTRIES ||
markdownParseCacheChars > MARKDOWN_PARSE_CACHE_MAX_CHARS
) {
const oldestContent = markdownParseCache.keys().next().value
if (typeof oldestContent !== 'string') break
markdownParseCache.delete(oldestContent)
markdownParseCacheChars -= oldestContent.length
if (streaming) {
evictStreamingMarkdownEntries()
} else {
finalizedMarkdownCacheChars += content.length
evictFinalizedMarkdownEntries()
}
return parsed
}
export const __markdownParseCacheInternals = {
finalizedSize: () => finalizedMarkdownCache.size,
streamingSize: () => streamingMarkdownCache.size,
finalizedChars: () => finalizedMarkdownCacheChars,
hasFinalized: (content: string) => finalizedMarkdownCache.has(buildMarkdownCacheKey(content)),
hasStreaming: (content: string) => streamingMarkdownCache.has(buildMarkdownCacheKey(content)),
reset: () => {
finalizedMarkdownCache.clear()
streamingMarkdownCache.clear()
finalizedMarkdownCacheChars = 0
},
}
const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
@ -396,10 +451,10 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
.join(' ')
}
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick }: Props) {
const { html, codeBlocks, mathBlocks } = useMemo(
() => cache ? getCachedMarkdownParse(content) : parseMarkdown(content),
[cache, content],
() => cache ? getCachedMarkdownParse(content, streaming) : parseMarkdown(content),
[cache, content, streaming],
)
const proseClasses = useMemo(
() => getProseClasses(variant, className),