perf(tui): lazily seed virtual history heights (#16523)

This commit is contained in:
kshitij 2026-04-27 07:55:45 -07:00 committed by GitHub
parent 9b55365f6f
commit 98d75dea5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 75 additions and 22 deletions

View file

@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureVirtualItemHeight } from '../hooks/useVirtualHistory.js'
describe('ensureVirtualItemHeight', () => {
it('reuses cached heights without invoking the estimator', () => {
const heights = new Map([['a', 7]])
const estimateHeight = vi.fn(() => 99)
expect(ensureVirtualItemHeight(heights, 'a', 0, 4, estimateHeight)).toBe(7)
expect(estimateHeight).not.toHaveBeenCalled()
expect(heights.get('a')).toBe(7)
})
it('lazily seeds missing heights from the estimator', () => {
const heights = new Map<string, number>()
const estimateHeight = vi.fn((index: number) => 10 + index)
expect(ensureVirtualItemHeight(heights, 'b', 2, 4, estimateHeight)).toBe(12)
expect(estimateHeight).toHaveBeenCalledTimes(1)
expect(estimateHeight).toHaveBeenCalledWith(2, 'b')
expect(heights.get('b')).toBe(12)
})
it('falls back to the default estimate when no estimator is provided', () => {
const heights = new Map<string, number>()
expect(ensureVirtualItemHeight(heights, 'c', 0, 4)).toBe(4)
expect(heights.get('c')).toBe(4)
})
it('normalizes non-positive estimates to a minimum of one row', () => {
const heights = new Map<string, number>()
const estimateHeight = vi.fn(() => 0)
expect(ensureVirtualItemHeight(heights, 'd', 0, 0, estimateHeight)).toBe(1)
expect(heights.get('d')).toBe(1)
})
})

View file

@ -218,23 +218,15 @@ export function useMainApp(gw: GatewayClient) {
return cache return cache
}, [heightCacheKey]) }, [heightCacheKey])
const initialHeights = useMemo(() => { const estimateRowHeight = useCallback(
const out = new Map<string, number>() (index: number) =>
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
for (const row of virtualRows) { compact: ui.compact,
out.set( details: detailsVisible,
row.key, limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS
heightCache.get(row.key) ?? }),
estimatedMsgHeight(row.msg, cols, { [cols, detailsVisible, ui.compact, virtualRows]
compact: ui.compact, )
details: detailsVisible,
limitHistory: row.index < virtualRows.length - FULL_RENDER_TAIL_ITEMS
})
)
}
return out
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
const syncHeightCache = useCallback( const syncHeightCache = useCallback(
(heights: ReadonlyMap<string, number>) => { (heights: ReadonlyMap<string, number>) => {
@ -250,7 +242,8 @@ export function useMainApp(gw: GatewayClient) {
) )
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, { const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, {
initialHeights, estimateHeight: estimateRowHeight,
initialHeights: heightCache,
liveTailActive: turnLiveTailActive, liveTailActive: turnLiveTailActive,
onHeightsChange: syncHeightCache onHeightsChange: syncHeightCache
}) })

View file

@ -76,12 +76,32 @@ export const shouldSetVirtualClamp = ({
viewportHeight: number viewportHeight: number
}) => itemCount > 0 && viewportHeight > 0 && !sticky && !liveTailActive }) => itemCount > 0 && viewportHeight > 0 && !sticky && !liveTailActive
export const ensureVirtualItemHeight = (
heights: Map<string, number>,
key: string,
index: number,
estimate: number,
estimateHeight?: (index: number, key: string) => number
) => {
const cached = heights.get(key)
if (cached !== undefined) {
return Math.max(1, Math.floor(cached))
}
const seeded = Math.max(1, Math.floor(estimateHeight?.(index, key) ?? estimate))
heights.set(key, seeded)
return seeded
}
export function useVirtualHistory( export function useVirtualHistory(
scrollRef: RefObject<ScrollBoxHandle | null>, scrollRef: RefObject<ScrollBoxHandle | null>,
items: readonly { key: string }[], items: readonly { key: string }[],
columns: number, columns: number,
{ {
estimate = ESTIMATE, estimate = ESTIMATE,
estimateHeight,
initialHeights, initialHeights,
liveTailActive = false, liveTailActive = false,
onHeightsChange, onHeightsChange,
@ -208,7 +228,7 @@ export function useVirtualHistory(
arr[0] = 0 arr[0] = 0
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
arr[i + 1] = arr[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate)) arr[i + 1] = arr[i]! + ensureVirtualItemHeight(heights.current, items[i]!.key, i, estimate, estimateHeight)
} }
offsetsCache.current = { arr, n, version: offsetVersion.current } offsetsCache.current = { arr, n, version: offsetVersion.current }
@ -280,7 +300,7 @@ export function useVirtualHistory(
let coverage = 0 let coverage = 0
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
coverage += heights.current.get(items[i]!.key) ?? PESSIMISTIC coverage += ensureVirtualItemHeight(heights.current, items[i]!.key, i, PESSIMISTIC, estimateHeight)
} }
if (sticky) { if (sticky) {
@ -288,13 +308,13 @@ export function useVirtualHistory(
while (start > minStart && coverage < needed) { while (start > minStart && coverage < needed) {
start-- start--
coverage += heights.current.get(items[start]!.key) ?? PESSIMISTIC coverage += ensureVirtualItemHeight(heights.current, items[start]!.key, start, PESSIMISTIC, estimateHeight)
} }
} else { } else {
const maxEnd = Math.min(n, start + maxMounted) const maxEnd = Math.min(n, start + maxMounted)
while (end < maxEnd && coverage < needed) { while (end < maxEnd && coverage < needed) {
coverage += heights.current.get(items[end]!.key) ?? PESSIMISTIC coverage += ensureVirtualItemHeight(heights.current, items[end]!.key, end, PESSIMISTIC, estimateHeight)
end++ end++
} }
} }
@ -498,6 +518,7 @@ interface MeasuredNode {
interface VirtualHistoryOptions { interface VirtualHistoryOptions {
coldStartCount?: number coldStartCount?: number
estimate?: number estimate?: number
estimateHeight?: (index: number, key: string) => number
initialHeights?: ReadonlyMap<string, number> initialHeights?: ReadonlyMap<string, number>
liveTailActive?: boolean liveTailActive?: boolean
maxMounted?: number maxMounted?: number