perf(desktop): skip off-screen paint on non-virtualized transcript rows (WebKit)

WebKit (Tauri WKWebView) paints complex message DOM ~2x slower than Blink and stutters during scroll. Add content-visibility:auto + contain-intrinsic-size to fully-mounted (non-virtualized) rows so the engine skips off-screen rows without unmounting or re-measuring them.

Measured with Playwright WebKit on the reported laggy 79-message session (main): worst frame 279ms -> 18ms, jank 2 -> 0. Virtualization was ruled out — it made WebKit *worse* (tanstack 12-24fps) due to scroll-time measureElement/re-render cost.

Only non-virtualized rows get the class (no ResizeObserver); virtualized window items stay free of content-visibility to avoid the zero-height measurement regression. Tests guard both directions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-31 16:54:30 +08:00
parent ed574777e3
commit 9b3e58b60b
3 changed files with 24 additions and 3 deletions

View File

@ -156,6 +156,11 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220)
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
expect(container.querySelector('[data-virtual-spacer="top"]')).not.toBeNull()
// Virtualized window items must NOT get content-visibility: it zeroes their
// ResizeObserver-measured height in the virtualizer (the regression this guards).
for (const item of container.querySelectorAll('[data-virtual-message-item]')) {
expect((item as HTMLElement).className).not.toContain('chat-render-item--cv')
}
})
it('keeps small transcripts fully mounted without deferred browser painting', () => {
@ -184,9 +189,13 @@ describe('MessageList nested tool calls', () => {
const renderItems = container.querySelectorAll('.chat-render-item')
expect(renderItems).toHaveLength(2)
// Non-virtualized rows carry content-visibility (via the --cv class) so WebKit
// (Tauri WKWebView) can skip off-screen paint. Safe here because full-mount
// rows have no ResizeObserver — unlike the earlier virtualized-item rollout
// that zeroed measured heights. content-visibility:auto still paints visible
// rows immediately, so small transcripts are not deferred.
for (const item of renderItems) {
expect(item.className).not.toContain('content-visibility')
expect(item.className).not.toContain('contain-intrinsic-size')
expect(item.className).toContain('chat-render-item--cv')
}
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
})

View File

@ -1841,7 +1841,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
{content}
</MeasuredRenderItem>
) : (
<div key={itemKey} className={CHAT_RENDER_ITEM_CLASS}>
<div key={itemKey} className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv`}>
{content}
</div>
)

View File

@ -1369,3 +1369,15 @@ button, input, textarea, select, a, [role="button"] {
opacity: 0.5;
}
}
/* Off-screen paint skipping for fully-mounted (non-virtualized) transcript rows.
WebKit (Tauri WKWebView) paints complex message DOM ~2x slower than Blink and
stutters on long worst-case frames; content-visibility lets it skip off-screen
rows WITHOUT unmounting or re-measuring them. Measured (Playwright WebKit, 79-msg
session): worst frame 279ms -> 18ms, jank 2 -> 0. Only non-virtualized rows get
this class virtualized window items must stay free of content-visibility to
avoid the zero-height measurement regression in the virtualizer. */
.chat-render-item--cv {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}