mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
659c63e5ba
commit
b8645b0122
@ -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" />
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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() && (
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user