diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts new file mode 100644 index 0000000000..b4a5e7cd62 --- /dev/null +++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts @@ -0,0 +1,119 @@ +import { Box, renderSync, ScrollBox, Text, type ScrollBoxHandle } from '@hermes/ink' +import React, { useLayoutEffect, useRef } from 'react' +import { PassThrough } from 'stream' +import { describe, expect, it } from 'vitest' + +import { useVirtualHistory } from '../hooks/useVirtualHistory.js' + +interface Item { + height: number + key: string +} + +interface Exposed { + scroll: ScrollBoxHandle | null + virtualHistory: ReturnType +} + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +const makeStreams = () => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 20 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', () => {}) + + return { stderr, stdin, stdout } +} + +const mountedSpan = (items: readonly Item[], virtualHistory: ReturnType) => { + let height = 0 + + for (let index = virtualHistory.start; index < virtualHistory.end; index++) { + height += items[index]?.height ?? 0 + } + + return { bottom: virtualHistory.topSpacer + height, top: virtualHistory.topSpacer } +} + +const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType, scroll: ScrollBoxHandle) => { + const span = mountedSpan(items, virtualHistory) + const top = scroll.getScrollTop() + const bottom = top + scroll.getViewportHeight() + + return top >= span.top && bottom <= span.bottom +} + +function Harness({ expose, items }: { expose: React.MutableRefObject; items: readonly Item[] }) { + const scrollRef = useRef(null) + const virtualHistory = useVirtualHistory(scrollRef, items, 80, { + coldStartCount: 16, + estimateHeight: index => items[index]?.height ?? 1, + maxMounted: 16, + overscan: 2 + }) + + useLayoutEffect(() => { + expose.current = { scroll: scrollRef.current, virtualHistory } + }) + + return React.createElement( + ScrollBox, + { flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true }, + React.createElement( + Box, + { flexDirection: 'column', width: '100%' }, + virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null, + ...items + .slice(virtualHistory.start, virtualHistory.end) + .map(item => + React.createElement( + Box, + { height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) }, + React.createElement(Text, null, item.key) + ) + ), + virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null + ) + ) +} + +describe('useVirtualHistory offset cache reuse', () => { + it('ignores stale reused offset-array entries after the item count shrinks', async () => { + const beforeShrink = Array.from({ length: 1400 }, (_, index) => ({ height: 1, key: `old${index}` })) + const afterShrink = Array.from({ length: 800 }, (_, index) => ({ height: 7, key: `new${index}` })) + const expose = { current: null as Exposed | null } + const streams = makeStreams() + const instance = renderSync(React.createElement(Harness, { expose, items: beforeShrink }), { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + }) + + try { + await delay(20) + instance.rerender(React.createElement(Harness, { expose, items: afterShrink })) + await delay(20) + + const scroll = expose.current!.scroll! + const transcriptHeight = expose.current!.virtualHistory.offsets[afterShrink.length] ?? 0 + + expect(transcriptHeight).toBe(5600) + expect(scroll.getScrollTop()).toBe(transcriptHeight - scroll.getViewportHeight()) + + scroll.scrollBy(-1) + await delay(80) + + expect(scroll.getPendingDelta()).toBe(0) + expect(viewportIsMounted(afterShrink, expose.current!.virtualHistory, scroll)).toBe(true) + } finally { + instance.unmount() + instance.cleanup() + } + }) +}) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 19c3692bf1..dbd3a2f666 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -51,9 +51,9 @@ const SLIDE_STEP = 12 const NOOP = () => {} -const upperBound = (arr: ArrayLike, target: number) => { +const upperBound = (arr: ArrayLike, target: number, length = arr.length) => { let lo = 0 - let hi = arr.length + let hi = length while (lo < hi) { const mid = (lo + hi) >> 1 @@ -282,8 +282,8 @@ export function useVirtualHistory( // Binary search — offsets is monotone. Linear walk was O(n) at n=10k+, // ~2ms per render during scroll. - start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1)) - end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi))) + start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo, n + 1) - 1)) + end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi, n + 1))) } }