/** * React-level virtualization hook for items inside a ScrollBox. * * Adapted from Claude Code's useVirtualScroll.ts. Core patterns extracted: * 1. ScrollTop quantization (40-row bins) for re-render gating * 2. Slide cap (25 items per commit) for smooth scroll during fast navigation * 3. Overscan (80 rows above/below) to absorb estimate errors * 4. Height measurement via Yoga layout nodes and caching * 5. Column-change scaling (proportional height adjustment, not cache clear) * 6. Range freezing during resize to prevent mount churn * * Made generic: no RenderableMessage dependency — works with any item type. * No TextHoverColorContext, ScrollChromeContext, or search/nav functionality. */ import type { RefObject } from 'react' import { useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from 'react' import type { ScrollBoxHandle } from '../components/ScrollBox.js' import type { DOMElement } from '../dom.js' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Estimated height (rows) for items not yet measured. Intentionally LOW. */ const DEFAULT_ESTIMATE = 3 /** Extra rows rendered above and below the viewport. */ const OVERSCAN_ROWS = 80 /** Items rendered before the ScrollBox has laid out (viewportHeight=0). */ const COLD_START_COUNT = 30 /** * scrollTop quantization bin size. Half of OVERSCAN_ROWS guarantees >=40 * rows of overscan remain before the new range is needed. */ const SCROLL_QUANTUM = OVERSCAN_ROWS >> 1 /** Worst-case height assumed for unmeasured items when computing coverage. */ const PESSIMISTIC_HEIGHT = 1 /** Cap on mounted items to bound fiber allocation. */ const MAX_MOUNTED_ITEMS = 300 /** Max NEW items to mount in a single commit. */ const SLIDE_STEP = 25 const NOOP_UNSUB = () => {} // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type VirtualScrollResult = { /** [startIndex, endIndex) half-open slice of items to render. */ range: readonly [number, number] /** Height (rows) of spacer before the first rendered item. */ topSpacer: number /** Height (rows) of spacer after the last rendered item. */ bottomSpacer: number /** * Callback ref factory. Attach `measureRef(itemKey)` to each rendered * item's root Box; after Yoga layout, the computed height is cached. */ measureRef: (key: string) => (el: DOMElement | null) => void /** * Attach to the topSpacer Box. Its Yoga computedTop IS listOrigin. */ spacerRef: RefObject /** * Cumulative y-offset of each item in list-wrapper coords. * offsets[i] = rows above item i; offsets[n] = totalHeight. */ offsets: ArrayLike /** Read Yoga computedTop for item at index. Returns -1 if not mounted. */ getItemTop: (index: number) => number /** Get the mounted DOMElement for item at index, or null. */ getItemElement: (index: number) => DOMElement | null /** Measured Yoga height. undefined = not yet measured; 0 = rendered nothing. */ getItemHeight: (index: number) => number | undefined /** Scroll so item i is in the mounted range. */ scrollToIndex: (i: number) => void } // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- /** * Virtual scroll hook for items inside a ScrollBox. * * The ScrollBox already does Ink-output-level viewport culling, but all React * fibers + Yoga nodes are still allocated. This hook mounts only items in * viewport + overscan, with spacer boxes holding the scroll height constant. * * @param scrollRef - Ref to the ScrollBox handle * @param itemKeys - Stable array of unique keys, one per item * @param columns - Terminal column count. On change, cached heights are scaled. */ export function useVirtualScroll( scrollRef: RefObject, itemKeys: readonly string[], columns: number, ): VirtualScrollResult { const heightCache = useRef(new Map()) const offsetVersionRef = useRef(0) const lastScrollTopRef = useRef(0) const offsetsRef = useRef<{ arr: Float64Array; version: number; n: number }>({ arr: new Float64Array(0), version: -1, n: -1, }) const itemRefs = useRef(new Map()) const refCache = useRef(new Map void>()) const prevColumns = useRef(columns) const skipMeasurementRef = useRef(false) const prevRangeRef = useRef(null) const freezeRendersRef = useRef(0) // Column change: scale cached heights instead of clearing them. if (prevColumns.current !== columns) { const ratio = prevColumns.current / columns prevColumns.current = columns for (const [k, h] of heightCache.current) { heightCache.current.set(k, Math.max(1, Math.round(h * ratio))) } offsetVersionRef.current++ skipMeasurementRef.current = true freezeRendersRef.current = 2 } const frozenRange = freezeRendersRef.current > 0 ? prevRangeRef.current : null const listOriginRef = useRef(0) const spacerRef = useRef(null) // useSyncExternalStore ties re-renders to imperative scroll. const subscribe = useCallback( (listener: () => void) => scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, [scrollRef], ) useSyncExternalStore(subscribe, () => { const s = scrollRef.current if (!s) return NaN const target = s.getScrollTop() + s.getPendingDelta() const bin = Math.floor(target / SCROLL_QUANTUM) return s.isSticky() ? ~bin : bin }) const scrollTop = scrollRef.current?.getScrollTop() ?? -1 const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0 const viewportH = scrollRef.current?.getViewportHeight() ?? 0 const isSticky = scrollRef.current?.isSticky() ?? true // GC stale cache entries when itemKeys changes. // eslint-disable-next-line react-hooks/exhaustive-deps useMemo(() => { const live = new Set(itemKeys) let dirty = false for (const k of heightCache.current.keys()) { if (!live.has(k)) { heightCache.current.delete(k) dirty = true } } for (const k of refCache.current.keys()) { if (!live.has(k)) refCache.current.delete(k) } if (dirty) offsetVersionRef.current++ }, [itemKeys]) // Rebuild offsets if version changed. const n = itemKeys.length if ( offsetsRef.current.version !== offsetVersionRef.current || offsetsRef.current.n !== n ) { const arr = offsetsRef.current.arr.length >= n + 1 ? offsetsRef.current.arr : new Float64Array(n + 1) arr[0] = 0 for (let i = 0; i < n; i++) { arr[i + 1] = arr[i]! + (heightCache.current.get(itemKeys[i]!) ?? DEFAULT_ESTIMATE) } offsetsRef.current = { arr, version: offsetVersionRef.current, n } } const offsets = offsetsRef.current.arr const totalHeight = offsets[n]! let start: number let end: number if (frozenRange) { ;[start, end] = frozenRange start = Math.min(start, n) end = Math.min(end, n) } else if (viewportH === 0 || scrollTop < 0) { // Cold start: render the tail. start = Math.max(0, n - COLD_START_COUNT) end = n } else { if (isSticky) { const budget = viewportH + OVERSCAN_ROWS start = n while (start > 0 && totalHeight - offsets[start - 1]! < budget) { start-- } end = n } else { const listOrigin = listOriginRef.current const MAX_SPAN_ROWS = viewportH * 3 const rawLo = Math.min(scrollTop, scrollTop + pendingDelta) const rawHi = Math.max(scrollTop, scrollTop + pendingDelta) const span = rawHi - rawLo const clampedLo = span > MAX_SPAN_ROWS ? pendingDelta < 0 ? rawHi - MAX_SPAN_ROWS : rawLo : rawLo const clampedHi = clampedLo + Math.min(span, MAX_SPAN_ROWS) const effLo = Math.max(0, clampedLo - listOrigin) const effHi = clampedHi - listOrigin const lo = effLo - OVERSCAN_ROWS // Binary search for start. { let l = 0 let r = n while (l < r) { const m = (l + r) >> 1 if (offsets[m + 1]! <= lo) l = m + 1 else r = m } start = l } // Guard: don't advance past mounted-but-unmeasured items. { const p = prevRangeRef.current if (p && p[0] < start) { for (let i = p[0]; i < Math.min(start, p[1]); i++) { const k = itemKeys[i]! if (itemRefs.current.has(k) && !heightCache.current.has(k)) { start = i break } } } } const needed = viewportH + 2 * OVERSCAN_ROWS const maxEnd = Math.min(n, start + MAX_MOUNTED_ITEMS) let coverage = 0 end = start while ( end < maxEnd && (coverage < needed || offsets[end]! < effHi + viewportH + OVERSCAN_ROWS) ) { coverage += heightCache.current.get(itemKeys[end]!) ?? PESSIMISTIC_HEIGHT end++ } } // Coverage guarantee for the isSticky path too. const needed = viewportH + 2 * OVERSCAN_ROWS const minStart = Math.max(0, end - MAX_MOUNTED_ITEMS) let coverage = 0 for (let i = start; i < end; i++) { coverage += heightCache.current.get(itemKeys[i]!) ?? PESSIMISTIC_HEIGHT } while (start > minStart && coverage < needed) { start-- coverage += heightCache.current.get(itemKeys[start]!) ?? PESSIMISTIC_HEIGHT } // Slide cap: limit how many NEW items mount this commit. const prev = prevRangeRef.current const scrollVelocity = Math.abs(scrollTop - lastScrollTopRef.current) + Math.abs(pendingDelta) if (prev && scrollVelocity > viewportH * 2) { const [pS, pE] = prev if (start < pS - SLIDE_STEP) start = pS - SLIDE_STEP if (end > pE + SLIDE_STEP) end = pE + SLIDE_STEP if (start > end) end = Math.min(start + SLIDE_STEP, n) } lastScrollTopRef.current = scrollTop } if (freezeRendersRef.current > 0) { freezeRendersRef.current-- } else { prevRangeRef.current = [start, end] } // useDeferredValue for range growth (expensive = fresh mounts). const dStart = useDeferredValue(start) const dEnd = useDeferredValue(end) let effStart = start < dStart ? dStart : start let effEnd = end > dEnd ? dEnd : end if (effStart > effEnd || isSticky) { effStart = start effEnd = end } if (pendingDelta > 0) { effEnd = end } // Final enforcement: cap mounted items. if (effEnd - effStart > MAX_MOUNTED_ITEMS) { const mid = (offsets[effStart]! + offsets[effEnd]!) / 2 if (scrollTop - listOriginRef.current < mid) { effEnd = effStart + MAX_MOUNTED_ITEMS } else { effStart = effEnd - MAX_MOUNTED_ITEMS } } // Clamp bounds for render-node-to-output. const listOrigin = listOriginRef.current const effTopSpacer = offsets[effStart]! const clampMin = effStart === 0 ? 0 : effTopSpacer + listOrigin const clampMax = effEnd === n ? Infinity : Math.max(effTopSpacer, offsets[effEnd]! - viewportH) + listOrigin useLayoutEffect(() => { if (isSticky) { scrollRef.current?.setClampBounds(undefined, undefined) } else { scrollRef.current?.setClampBounds(clampMin, clampMax) } }) // Measure heights from the PREVIOUS Ink render. useLayoutEffect(() => { const spacerYoga = spacerRef.current?.yogaNode if (spacerYoga && spacerYoga.getComputedWidth() > 0) { listOriginRef.current = spacerYoga.getComputedTop() } if (skipMeasurementRef.current) { skipMeasurementRef.current = false return } let anyChanged = false for (const [key, el] of itemRefs.current) { const yoga = el.yogaNode if (!yoga) continue const h = yoga.getComputedHeight() const prev = heightCache.current.get(key) if (h > 0) { if (prev !== h) { heightCache.current.set(key, h) anyChanged = true } } else if (yoga.getComputedWidth() > 0 && prev !== 0) { heightCache.current.set(key, 0) anyChanged = true } } if (anyChanged) offsetVersionRef.current++ }) // Stable per-key callback refs. const measureRef = useCallback((key: string) => { let fn = refCache.current.get(key) if (!fn) { fn = (el: DOMElement | null) => { if (el) { itemRefs.current.set(key, el) } else { const yoga = itemRefs.current.get(key)?.yogaNode if (yoga && !skipMeasurementRef.current) { const h = yoga.getComputedHeight() if ( (h > 0 || yoga.getComputedWidth() > 0) && heightCache.current.get(key) !== h ) { heightCache.current.set(key, h) offsetVersionRef.current++ } } itemRefs.current.delete(key) } } refCache.current.set(key, fn) } return fn }, []) const getItemTop = useCallback( (index: number) => { const yoga = itemRefs.current.get(itemKeys[index]!)?.yogaNode if (!yoga || yoga.getComputedWidth() === 0) return -1 return yoga.getComputedTop() }, [itemKeys], ) const getItemElement = useCallback( (index: number) => itemRefs.current.get(itemKeys[index]!) ?? null, [itemKeys], ) const getItemHeight = useCallback( (index: number) => heightCache.current.get(itemKeys[index]!), [itemKeys], ) const scrollToIndex = useCallback( (i: number) => { const o = offsetsRef.current if (i < 0 || i >= o.n) return scrollRef.current?.scrollTo(o.arr[i]! + listOriginRef.current) }, [scrollRef], ) const effBottomSpacer = totalHeight - offsets[effEnd]! return { range: [effStart, effEnd], topSpacer: effTopSpacer, bottomSpacer: effBottomSpacer, measureRef, spacerRef, offsets, getItemTop, getItemElement, getItemHeight, scrollToIndex, } }