cc-haha/desktop/src/components/chat/virtualHeightCache.ts
程序员阿江(Relakkes) 4a55b11c16 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>
2026-05-23 23:50:49 +08:00

69 lines
2.1 KiB
TypeScript

// 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()
},
}