perf(desktop): persist virtual height cache across tab switches and defer tab-switch work

Tab switches into a long session paid for everything that was already paid for
the last time the user was on that session. Specifically the per-message height
and metric maps were cleared on every switch, so the new commit could only feed
estimated heights into buildVirtualTranscriptWindow and then chase them with
ResizeObserver callbacks. The first commit also ran getBranchableMessageTargets
and getCompletedTurnTargets — two extra O(N) walks over the messages array —
synchronously, and scrollToBottom('auto') was called inside the switch's
useLayoutEffect, which (on JSDOM and depending on layout state) added a
scrollHeight read to that critical path.

Introduces virtualHeightCache, a small module-level LRU keyed by sessionId that
holds the height and metric maps. MessageList now reads the prior maps on
switch instead of clearing them, so revisiting a session uses real measured
heights from the previous visit and skips the estimate→measure-correction
cascade. tabStore.closeTab calls dropSession so closed tabs don't leak.

The two O(N) branch / completed-turn computations now read from
useDeferredValue(messages), so they run as a low-priority follow-up render
after the first paint instead of blocking the switch commit. The switch's
useLayoutEffect now writes the bottom sentinel directly instead of calling
scrollToBottom, keeping the layout-read path out of the synchronous switch.

Tested: 731/731 desktop vitest suites pass, including 5 new virtualHeightCache
LRU/isolation/restore cases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 23:50:49 +08:00
parent b8645b0122
commit 4a55b11c16
4 changed files with 184 additions and 14 deletions

View File

@ -1,4 +1,4 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type ReactNode } from 'react'
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'
@ -24,6 +24,11 @@ import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
import {
getHeightsForSession,
getMetricsForSession,
type VirtualRenderItemMetric,
} from './virtualHeightCache'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
@ -831,12 +836,6 @@ type VirtualTranscriptWindow = {
items: VirtualTranscriptItem[]
}
type VirtualRenderItemMetric = {
signature: string
contentWeight: number
estimatedHeight: number
}
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
function isNearScrollBottom(element: HTMLElement) {
@ -1233,8 +1232,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
(chatState === 'thinking' && Boolean(activeThinkingId))
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const virtualItemHeightsRef = useRef(new Map<string, number>())
const virtualItemMetricCacheRef = useRef(new Map<string, VirtualRenderItemMetric>())
const virtualItemHeightsRef = useRef<Map<string, number>>(
resolvedSessionId ? getHeightsForSession(resolvedSessionId) : new Map<string, number>(),
)
const virtualItemMetricCacheRef = useRef<Map<string, VirtualRenderItemMetric>>(
resolvedSessionId ? getMetricsForSession(resolvedSessionId) : new Map<string, VirtualRenderItemMetric>(),
)
const pendingMeasuredHeightsRef = useRef(false)
const measureFlushFrameRef = useRef<number | null>(null)
const lastAutoScrollAtRef = useRef(0)
@ -1393,8 +1396,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
lastSessionIdRef.current = resolvedSessionId
virtualItemHeightsRef.current.clear()
virtualItemMetricCacheRef.current.clear()
virtualItemHeightsRef.current = resolvedSessionId
? getHeightsForSession(resolvedSessionId)
: new Map<string, number>()
virtualItemMetricCacheRef.current = resolvedSessionId
? getMetricsForSession(resolvedSessionId)
: new Map<string, VirtualRenderItemMetric>()
pendingMeasuredHeightsRef.current = false
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
@ -1412,7 +1419,29 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
}))
setShowJumpToLatest(true)
} else if (container) {
// Switch to a session we were at the bottom of (or first visit): write
// the bottom sentinel without going through scrollToBottom's read path,
// so we never force a layout flush during the switch's commit.
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
ignoreProgrammaticScrollTopRef.current = null
lastAutoScrollAtRef.current = performance.now()
shouldAutoScrollRef.current = true
setScrollToBottomWithoutLayoutRead(container, 'auto')
setVirtualViewport((current) => ({
scrollTop: SCROLL_BOTTOM_SENTINEL,
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
}))
setShowJumpToLatest(false)
if (resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: container.scrollTop,
wasAtBottom: true,
})
}
} else {
// No container yet (initial mount before ref settles): fall back to the
// existing scrollToBottom path which is safe pre-mount.
scrollToBottom('auto')
}
}
@ -1465,11 +1494,21 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages, activeAskUserQuestionToolUseId),
[activeAskUserQuestionToolUseId, messages],
)
// Defer the per-message branchable / completed-turn computations so the first
// commit on tab switch can render the virtualization window without doing two
// additional O(N) walks synchronously. They re-run in a low-priority render
// once the initial frame is painted.
const deferredMessages = useDeferredValue(messages)
const branchableMessageTargets = useMemo(
() => branchActionsDisabled ? new Map<string, BranchableMessageTarget>() : getBranchableMessageTargets(messages),
[branchActionsDisabled, messages],
() => branchActionsDisabled
? new Map<string, BranchableMessageTarget>()
: getBranchableMessageTargets(deferredMessages),
[branchActionsDisabled, deferredMessages],
)
const completedTurnTargets = useMemo(
() => getCompletedTurnTargets(deferredMessages),
[deferredMessages],
)
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
const latestCompletedTurnId =
completedTurnTargets.length > 0
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null

View File

@ -0,0 +1,61 @@
import { beforeEach, describe, it, expect } from 'vitest'
import {
__virtualHeightCacheInternals,
dropSession,
getHeightsForSession,
getMetricsForSession,
} from './virtualHeightCache'
describe('virtualHeightCache', () => {
beforeEach(() => {
__virtualHeightCacheInternals.reset()
})
it('returns the same map instance across calls for the same session', () => {
const a = getHeightsForSession('session-a')
a.set('msg-1', 100)
const aAgain = getHeightsForSession('session-a')
expect(aAgain).toBe(a)
expect(aAgain.get('msg-1')).toBe(100)
})
it('isolates per-session entries', () => {
getHeightsForSession('s1').set('x', 42)
getHeightsForSession('s2').set('x', 99)
expect(getHeightsForSession('s1').get('x')).toBe(42)
expect(getHeightsForSession('s2').get('x')).toBe(99)
})
it('evicts the least recently used session beyond the LRU bound', () => {
for (let i = 0; i < 18; i++) {
getHeightsForSession(`session-${i}`).set('marker', i)
}
expect(__virtualHeightCacheInternals.size()).toBe(16)
// session-0 should have been evicted (oldest, never re-touched)
const restored = getHeightsForSession('session-0')
expect(restored.size).toBe(0)
})
it('dropSession removes both height and metric maps', () => {
getHeightsForSession('s').set('a', 1)
getMetricsForSession('s').set('a', { signature: 'sig', contentWeight: 10, estimatedHeight: 20 })
dropSession('s')
expect(getHeightsForSession('s').size).toBe(0)
expect(getMetricsForSession('s').size).toBe(0)
})
it('switching back to a previously visited session preserves measurements', () => {
const original = getHeightsForSession('long-session')
original.set('item-1', 240)
original.set('item-2', 360)
// simulate switching to another session, then back
getHeightsForSession('other-session').set('item-1', 100)
const restored = getHeightsForSession('long-session')
expect(restored.get('item-1')).toBe(240)
expect(restored.get('item-2')).toBe(360)
})
})

View File

@ -0,0 +1,68 @@
// Module-level caches keyed by sessionId so switching tabs back to a previously
// rendered long transcript can skip estimate→measure thrash. Bounded by an LRU
// across sessions to avoid unbounded growth across long-running sessions.
export type VirtualRenderItemMetric = {
signature: string
contentWeight: number
estimatedHeight: number
}
const MAX_TRACKED_SESSIONS = 16
const sessionHeightCache = new Map<string, Map<string, number>>()
const sessionMetricCache = new Map<string, Map<string, VirtualRenderItemMetric>>()
function touchSession(sessionId: string, map: Map<string, Map<string, unknown>>) {
// Reinsert to move to LRU tail.
const existing = map.get(sessionId)
if (existing) {
map.delete(sessionId)
map.set(sessionId, existing)
}
}
function evictSessionsBeyondLimit(): void {
while (sessionHeightCache.size > MAX_TRACKED_SESSIONS) {
const oldest = sessionHeightCache.keys().next().value
if (typeof oldest !== 'string') break
sessionHeightCache.delete(oldest)
sessionMetricCache.delete(oldest)
}
}
export function getHeightsForSession(sessionId: string): Map<string, number> {
let heights = sessionHeightCache.get(sessionId)
if (!heights) {
heights = new Map<string, number>()
sessionHeightCache.set(sessionId, heights)
evictSessionsBeyondLimit()
} else {
touchSession(sessionId, sessionHeightCache as Map<string, Map<string, unknown>>)
}
return heights
}
export function getMetricsForSession(sessionId: string): Map<string, VirtualRenderItemMetric> {
let metrics = sessionMetricCache.get(sessionId)
if (!metrics) {
metrics = new Map<string, VirtualRenderItemMetric>()
sessionMetricCache.set(sessionId, metrics)
} else {
touchSession(sessionId, sessionMetricCache as Map<string, Map<string, unknown>>)
}
return metrics
}
export function dropSession(sessionId: string): void {
sessionHeightCache.delete(sessionId)
sessionMetricCache.delete(sessionId)
}
export const __virtualHeightCacheInternals = {
size: () => sessionHeightCache.size,
reset: () => {
sessionHeightCache.clear()
sessionMetricCache.clear()
},
}

View File

@ -1,5 +1,6 @@
import { create } from 'zustand'
import { sessionsApi } from '../api/sessions'
import { dropSession as dropVirtualHeightSession } from '../components/chat/virtualHeightCache'
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
@ -108,6 +109,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
set({ tabs: newTabs, activeTabId: newActiveId })
get().saveTabs()
dropVirtualHeightSession(sessionId)
},
setActiveTab: (sessionId) => {