perf(desktop): stabilize virtualized transcript scroll and batch height measurements

Long desktop transcripts (797+ messages / 2.6MB) showed 1-2s blank frames while
scrolling. The previous strategy inflated virtualization overscan from 360px to
7200px on every wheel/touch/pointer event and routed scroll-handler state
updates through flushSync, which serialized large React reconciles onto WebKit's
compositor thread and starved paint.

Replaces the dynamic overscan + idle-timer state machine with a single stable
1200px overscan, and removes the flushSync barrier in updateAutoScrollState so
React 18's automatic batching handles the scroll-handler setStates. ResizeObserver
height callbacks now mark a pending bit and flush a single setMeasuredItemsVersion
per animation frame, instead of triggering a list-wide re-render per measured
item. Auto-scroll-on-measure is also removed so measurement feedback no longer
fights an active user scroll.

Tested: 722/722 desktop vitest suites pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 23:37:15 +08:00
parent 91224cbd20
commit 147b744abd

View File

@ -1,5 +1,4 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { flushSync } from 'react-dom'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
@ -787,9 +786,7 @@ const SCROLL_BOTTOM_SENTINEL = 1_000_000_000
const MAX_SCROLL_SNAPSHOTS = 100
const VIRTUALIZE_MIN_RENDER_ITEMS = 120
const VIRTUALIZE_MIN_CONTENT_CHARS = 120_000
const VIRTUAL_IDLE_OVERSCAN_PX = 360
const VIRTUAL_SCROLL_OVERSCAN_PX = 7200
const VIRTUAL_SCROLL_IDLE_DELAY_MS = 180
const VIRTUAL_OVERSCAN_PX = 1200
const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720
const VIRTUAL_MIN_ITEM_HEIGHT = 48
const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
@ -1195,7 +1192,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const scrollContentRef = useRef<HTMLDivElement>(null)
const virtualItemHeightsRef = useRef(new Map<string, number>())
const virtualItemMetricCacheRef = useRef(new Map<string, VirtualRenderItemMetric>())
const virtualScrollIdleTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null)
const pendingMeasuredHeightsRef = useRef(false)
const measureFlushFrameRef = useRef<number | null>(null)
const lastAutoScrollAtRef = useRef(0)
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const ignoreProgrammaticScrollUntilRef = useRef(0)
@ -1215,7 +1214,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
scrollTop: SCROLL_BOTTOM_SENTINEL,
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
})
const [isVirtualScrollActive, setIsVirtualScrollActive] = useState(false)
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
const branchActionsDisabled =
isMemberSession ||
@ -1228,8 +1226,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
message.type === 'compact_summary' && message.phase === 'compacting')
useEffect(() => () => {
if (virtualScrollIdleTimerRef.current) {
window.clearTimeout(virtualScrollIdleTimerRef.current)
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
}
}, [])
@ -1250,26 +1248,11 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
})
}, [])
const markVirtualScrollActive = useCallback(() => {
setIsVirtualScrollActive(true)
if (virtualScrollIdleTimerRef.current) {
window.clearTimeout(virtualScrollIdleTimerRef.current)
}
virtualScrollIdleTimerRef.current = window.setTimeout(() => {
setIsVirtualScrollActive(false)
virtualScrollIdleTimerRef.current = null
}, VIRTUAL_SCROLL_IDLE_DELAY_MS)
}, [])
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
isProgrammaticScrollingRef.current = true
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
setIsVirtualScrollActive(false)
if (virtualScrollIdleTimerRef.current) {
window.clearTimeout(virtualScrollIdleTimerRef.current)
virtualScrollIdleTimerRef.current = null
}
lastAutoScrollAtRef.current = performance.now()
const container = scrollContainerRef.current
let requestedScrollTop: number | null = null
if (container) {
@ -1311,20 +1294,33 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
})
}, [resolvedSessionId])
const flushMeasuredHeightVersion = useCallback(() => {
if (!pendingMeasuredHeightsRef.current) return
pendingMeasuredHeightsRef.current = false
setMeasuredItemsVersion((version) => version + 1)
}, [])
const handleVirtualItemHeightChange = useCallback((itemKey: string, height: number) => {
const measuredHeight = clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
const previousHeight = virtualItemHeightsRef.current.get(itemKey)
if (previousHeight !== undefined && Math.abs(previousHeight - measuredHeight) < 1) return
virtualItemHeightsRef.current.set(itemKey, measuredHeight)
setMeasuredItemsVersion((version) => version + 1)
if (shouldAutoScrollRef.current) {
requestAnimationFrame(() => {
if (shouldAutoScrollRef.current) scrollToBottom('auto')
if (typeof requestAnimationFrame === 'undefined') {
pendingMeasuredHeightsRef.current = true
flushMeasuredHeightVersion()
} else if (!pendingMeasuredHeightsRef.current) {
pendingMeasuredHeightsRef.current = true
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
}
measureFlushFrameRef.current = requestAnimationFrame(() => {
measureFlushFrameRef.current = null
flushMeasuredHeightVersion()
})
}
}, [scrollToBottom])
}, [flushMeasuredHeightVersion])
const updateAutoScrollState = useCallback(() => {
// Ignore scroll events triggered by our own programmatic scrolling to
@ -1339,9 +1335,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
syncVirtualViewportFromContainer(container)
return
}
flushSync(() => {
syncVirtualViewportFromContainer(container)
})
syncVirtualViewportFromContainer(container)
const isAtBottom = isNearScrollBottom(container)
shouldAutoScrollRef.current = isAtBottom
setShowJumpToLatest(!isAtBottom)
@ -1358,7 +1352,11 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
lastSessionIdRef.current = resolvedSessionId
virtualItemHeightsRef.current.clear()
virtualItemMetricCacheRef.current.clear()
setIsVirtualScrollActive(false)
pendingMeasuredHeightsRef.current = false
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
measureFlushFrameRef.current = null
}
setMeasuredItemsVersion((version) => version + 1)
const container = scrollContainerRef.current
@ -1458,9 +1456,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}),
[renderItemKeys, renderItems],
)
const virtualOverscanPx = isVirtualScrollActive
? VIRTUAL_SCROLL_OVERSCAN_PX
: VIRTUAL_IDLE_OVERSCAN_PX
const virtualTranscriptWindow = useMemo(
() => buildVirtualTranscriptWindow(
renderItems,
@ -1468,9 +1463,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
renderItemMetrics,
virtualItemHeightsRef.current,
virtualViewport,
virtualOverscanPx,
VIRTUAL_OVERSCAN_PX,
),
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualOverscanPx, virtualViewport],
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport],
)
const confirmTurnCard = useMemo(
() => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
@ -1711,9 +1706,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
onWheel={markVirtualScrollActive}
onTouchMove={markVirtualScrollActive}
onPointerDown={markVirtualScrollActive}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div