/ hooks / useVirtualScroll.ts
useVirtualScroll.ts
  1  import type { RefObject } from 'react'
  2  import {
  3    useCallback,
  4    useDeferredValue,
  5    useLayoutEffect,
  6    useMemo,
  7    useRef,
  8    useSyncExternalStore,
  9  } from 'react'
 10  import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'
 11  import type { DOMElement } from '../ink/dom.js'
 12  
 13  /**
 14   * Estimated height (rows) for items not yet measured. Intentionally LOW:
 15   * overestimating causes blank space (we stop mounting too early and the
 16   * viewport bottom shows empty spacer), while underestimating just mounts
 17   * a few extra items into overscan. The asymmetry means we'd rather err low.
 18   */
 19  const DEFAULT_ESTIMATE = 3
 20  /**
 21   * Extra rows rendered above and below the viewport. Generous because real
 22   * heights can be 10x the estimate for long tool results.
 23   */
 24  const OVERSCAN_ROWS = 80
 25  /** Items rendered before the ScrollBox has laid out (viewportHeight=0). */
 26  const COLD_START_COUNT = 30
 27  /**
 28   * scrollTop quantization for the useSyncExternalStore snapshot. Without
 29   * this, every wheel tick (3-5 per notch) triggers a full React commit +
 30   * Yoga calculateLayout() + Ink diff cycle — the CPU spike. Visual scroll
 31   * stays smooth regardless: ScrollBox.forceRender fires on every scrollBy
 32   * and Ink reads the REAL scrollTop from the DOM node, independent of what
 33   * React thinks. React only needs to re-render when the mounted range must
 34   * shift; half of OVERSCAN_ROWS is the tightest safe bin (guarantees ≥40
 35   * rows of overscan remain before the new range is needed).
 36   */
 37  const SCROLL_QUANTUM = OVERSCAN_ROWS >> 1
 38  /**
 39   * Worst-case height assumed for unmeasured items when computing coverage.
 40   * A MessageRow can be as small as 1 row (single-line tool call). Using 1
 41   * here guarantees the mounted span physically reaches the viewport bottom
 42   * regardless of how small items actually are — at the cost of over-mounting
 43   * when items are larger (which is fine, overscan absorbs it).
 44   */
 45  const PESSIMISTIC_HEIGHT = 1
 46  /** Cap on mounted items to bound fiber allocation even in degenerate cases. */
 47  const MAX_MOUNTED_ITEMS = 300
 48  /**
 49   * Max NEW items to mount in a single commit. Scrolling into a fresh range
 50   * with PESSIMISTIC_HEIGHT=1 would mount 194 items at once (OVERSCAN_ROWS*2+
 51   * viewportH = 194); each fresh MessageRow render costs ~1.5ms (marked lexer
 52   * + formatToken + ~11 createInstance) = ~290ms sync block. Sliding the range
 53   * toward the target over multiple commits keeps per-commit mount cost
 54   * bounded. The render-time clamp (scrollClampMin/Max) holds the viewport at
 55   * the edge of mounted content so there's no blank during catch-up.
 56   */
 57  const SLIDE_STEP = 25
 58  
 59  const NOOP_UNSUB = () => {}
 60  
 61  export type VirtualScrollResult = {
 62    /** [startIndex, endIndex) half-open slice of items to render. */
 63    range: readonly [number, number]
 64    /** Height (rows) of spacer before the first rendered item. */
 65    topSpacer: number
 66    /** Height (rows) of spacer after the last rendered item. */
 67    bottomSpacer: number
 68    /**
 69     * Callback ref factory. Attach `measureRef(itemKey)` to each rendered
 70     * item's root Box; after Yoga layout, the computed height is cached.
 71     */
 72    measureRef: (key: string) => (el: DOMElement | null) => void
 73    /**
 74     * Attach to the topSpacer Box. Its Yoga computedTop IS listOrigin
 75     * (first child of the virtualized region, so its top = cumulative
 76     * height of everything rendered before the list in the ScrollBox).
 77     * Drift-free: no subtraction of offsets, no dependence on item
 78     * heights that change between renders (tmux resize).
 79     */
 80    spacerRef: RefObject<DOMElement | null>
 81    /**
 82     * Cumulative y-offset of each item in list-wrapper coords (NOT scrollbox
 83     * coords — logo/siblings before this list shift the origin).
 84     * offsets[i] = rows above item i; offsets[n] = totalHeight.
 85     * Recomputed every render — don't memo on identity.
 86     */
 87    offsets: ArrayLike<number>
 88    /**
 89     * Read Yoga computedTop for item at index. Returns -1 if the item isn't
 90     * mounted or hasn't been laid out. Item Boxes are direct Yoga children
 91     * of the ScrollBox content wrapper (fragments collapse in the Ink DOM),
 92     * so this is content-wrapper-relative — same coordinate space as
 93     * scrollTop. Yoga layout is scroll-independent (translation happens
 94     * later in renderNodeToOutput), so positions stay valid across scrolls
 95     * without waiting for Ink to re-render. StickyTracker walks the mount
 96     * range with this to find the viewport boundary at per-scroll-tick
 97     * granularity (finer than the 40-row quantum this hook re-renders at).
 98     */
 99    getItemTop: (index: number) => number
100    /**
101     * Get the mounted DOMElement for item at index, or null. For
102     * ScrollBox.scrollToElement — anchoring by element ref defers the
103     * Yoga-position read to render time (deterministic; no throttle race).
104     */
105    getItemElement: (index: number) => DOMElement | null
106    /** Measured Yoga height. undefined = not yet measured; 0 = rendered nothing. */
107    getItemHeight: (index: number) => number | undefined
108    /**
109     * Scroll so item `i` is in the mounted range. Sets scrollTop =
110     * offsets[i] + listOrigin. The range logic finds start from
111     * scrollTop vs offsets[] — BOTH use the same offsets value, so they
112     * agree by construction regardless of whether offsets[i] is the
113     * "true" position. Item i mounts; its screen position may be off by
114     * a few-dozen rows (overscan-worth of estimate drift), but it's in
115     * the DOM. Follow with getItemTop(i) for the precise position.
116     */
117    scrollToIndex: (i: number) => void
118  }
119  
120  /**
121   * React-level virtualization for items inside a ScrollBox.
122   *
123   * The ScrollBox already does Ink-output-level viewport culling
124   * (render-node-to-output.ts:617 skips children outside the visible window),
125   * but all React fibers + Yoga nodes are still allocated. At ~250 KB RSS per
126   * MessageRow, a 1000-message session costs ~250 MB of grow-only memory
127   * (Ink screen buffer, WASM linear memory, JSC page retention all grow-only).
128   *
129   * This hook mounts only items in viewport + overscan. Spacer boxes hold the
130   * scroll height constant for the rest at O(1) fiber cost each.
131   *
132   * Height estimation: fixed DEFAULT_ESTIMATE for unmeasured items, replaced
133   * by real Yoga heights after first layout. No scroll anchoring — overscan
134   * absorbs estimate errors. If drift is noticeable in practice, anchoring
135   * (scrollBy(delta) when topSpacer changes) is a straightforward followup.
136   *
137   * stickyScroll caveat: render-node-to-output.ts:450 sets scrollTop=maxScroll
138   * during Ink's render phase, which does NOT fire ScrollBox.subscribe. The
139   * at-bottom check below handles this — when pinned to the bottom, we render
140   * the last N items regardless of what scrollTop claims.
141   */
142  export function useVirtualScroll(
143    scrollRef: RefObject<ScrollBoxHandle | null>,
144    itemKeys: readonly string[],
145    /**
146     * Terminal column count. On change, cached heights are stale (text
147     * rewraps) — SCALED by oldCols/newCols rather than cleared. Clearing
148     * made the pessimistic coverage back-walk mount ~190 items (every
149     * uncached item → PESSIMISTIC_HEIGHT=1 → walk 190 to reach
150     * viewport+2×overscan). Each fresh mount runs marked.lexer + syntax
151     * highlighting ≈ 3ms; ~600ms React reconcile on first resize with a
152     * long conversation. Scaling keeps heightCache populated → back-walk
153     * uses real-ish heights → mount range stays tight. Scaled estimates
154     * are overwritten by real Yoga heights on next useLayoutEffect.
155     *
156     * Scaled heights are close enough that the black-screen-on-widen bug
157     * (inflated pre-resize offsets overshoot post-resize scrollTop → end
158     * loop stops short of tail) doesn't trigger: ratio<1 on widen scales
159     * heights DOWN, keeping offsets roughly aligned with post-resize Yoga.
160     */
161    columns: number,
162  ): VirtualScrollResult {
163    const heightCache = useRef(new Map<string, number>())
164    // Bump whenever heightCache mutates so offsets rebuild on next read. Ref
165    // (not state) — checked during render phase, zero extra commits.
166    const offsetVersionRef = useRef(0)
167    // scrollTop at last commit, for detecting fast-scroll mode (slide cap gate).
168    const lastScrollTopRef = useRef(0)
169    const offsetsRef = useRef<{ arr: Float64Array; version: number; n: number }>({
170      arr: new Float64Array(0),
171      version: -1,
172      n: -1,
173    })
174    const itemRefs = useRef(new Map<string, DOMElement>())
175    const refCache = useRef(new Map<string, (el: DOMElement | null) => void>())
176    // Inline ref-compare: must run before offsets is computed below. The
177    // skip-flag guards useLayoutEffect from re-populating heightCache with
178    // PRE-resize Yoga heights (useLayoutEffect reads Yoga from the frame
179    // BEFORE this render's calculateLayout — the one that had the old width).
180    // Next render's useLayoutEffect reads post-resize Yoga → correct.
181    const prevColumns = useRef(columns)
182    const skipMeasurementRef = useRef(false)
183    // Freeze the mount range for the resize-settling cycle. Already-mounted
184    // items have warm useMemo (marked.lexer, highlighting); recomputing range
185    // from scaled/pessimistic estimates causes mount/unmount churn (~3ms per
186    // fresh mount = ~150ms visible as a second flash). The pre-resize range is
187    // as good as any — items visible at old width are what the user wants at
188    // new width. Frozen for 2 renders: render #1 has skipMeasurement (Yoga
189    // still pre-resize), render #2's useLayoutEffect reads post-resize Yoga
190    // into heightCache. Render #3 has accurate heights → normal recompute.
191    const prevRangeRef = useRef<readonly [number, number] | null>(null)
192    const freezeRendersRef = useRef(0)
193    if (prevColumns.current !== columns) {
194      const ratio = prevColumns.current / columns
195      prevColumns.current = columns
196      for (const [k, h] of heightCache.current) {
197        heightCache.current.set(k, Math.max(1, Math.round(h * ratio)))
198      }
199      offsetVersionRef.current++
200      skipMeasurementRef.current = true
201      freezeRendersRef.current = 2
202    }
203    const frozenRange = freezeRendersRef.current > 0 ? prevRangeRef.current : null
204    // List origin in content-wrapper coords. scrollTop is content-wrapper-
205    // relative, but offsets[] are list-local (0 = first virtualized item).
206    // Siblings that render BEFORE this list inside the ScrollBox — Logo,
207    // StatusNotices, truncation divider in Messages.tsx — shift item Yoga
208    // positions by their cumulative height. Without subtracting this, the
209    // non-sticky branch's effLo/effHi are inflated and start advances past
210    // items that are actually in view (blank viewport on click/scroll when
211    // sticky breaks while scrollTop is near max). Read from the topSpacer's
212    // Yoga computedTop — it's the first child of the virtualized region, so
213    // its top IS listOrigin. No subtraction of offsets → no drift when item
214    // heights change between renders (tmux resize: columns change → re-wrap
215    // → heights shrink → the old item-sample subtraction went negative →
216    // effLo inflated → black screen). One-frame lag like heightCache.
217    const listOriginRef = useRef(0)
218    const spacerRef = useRef<DOMElement | null>(null)
219  
220    // useSyncExternalStore ties re-renders to imperative scroll. Snapshot is
221    // scrollTop QUANTIZED to SCROLL_QUANTUM bins — Object.is sees no change
222    // for small scrolls (most wheel ticks), so React skips the commit + Yoga
223    // + Ink cycle entirely until the accumulated delta crosses a bin.
224    // Sticky is folded into the snapshot (sign bit) so sticky→broken also
225    // triggers: scrollToBottom sets sticky=true without moving scrollTop
226    // (Ink moves it later), and the first scrollBy after may land in the
227    // same bin. NaN sentinel = ref not attached.
228    const subscribe = useCallback(
229      (listener: () => void) =>
230        scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB,
231      [scrollRef],
232    )
233    useSyncExternalStore(subscribe, () => {
234      const s = scrollRef.current
235      if (!s) return NaN
236      // Snapshot uses the TARGET (scrollTop + pendingDelta), not committed
237      // scrollTop. scrollBy only mutates pendingDelta (renderer drains it
238      // across frames); committed scrollTop lags. Using target means
239      // notify() on scrollBy actually changes the snapshot → React remounts
240      // children for the destination before Ink's drain frames need them.
241      const target = s.getScrollTop() + s.getPendingDelta()
242      const bin = Math.floor(target / SCROLL_QUANTUM)
243      return s.isSticky() ? ~bin : bin
244    })
245    // Read the REAL committed scrollTop (not quantized) for range math —
246    // quantization is only the re-render gate, not the position.
247    const scrollTop = scrollRef.current?.getScrollTop() ?? -1
248    // Range must span BOTH committed scrollTop (where Ink is rendering NOW)
249    // and target (where pending will drain to). During drain, intermediate
250    // frames render at scrollTops between the two — if we only mount for
251    // the target, those frames find no children (blank rows).
252    const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0
253    const viewportH = scrollRef.current?.getViewportHeight() ?? 0
254    // True means the ScrollBox is pinned to the bottom. This is the ONLY
255    // stable "at bottom" signal: scrollTop/scrollHeight both reflect the
256    // PREVIOUS render's layout, which depends on what WE rendered (topSpacer +
257    // items), creating a feedback loop (range → layout → atBottom → range).
258    // stickyScroll is set by user action (scrollToBottom/scrollBy), the initial
259    // attribute, AND by render-node-to-output when its positional follow fires
260    // (scrollTop>=prevMax → pin to new max → set flag). The renderer write is
261    // feedback-safe: it only flips false→true, only when already at the
262    // positional bottom, and the flag being true here just means "tail-walk,
263    // clear clamp" — the same behavior as if we'd read scrollTop==maxScroll
264    // directly, minus the instability. Default true: before the ref attaches,
265    // assume bottom (sticky will pin us there on first Ink render).
266    const isSticky = scrollRef.current?.isSticky() ?? true
267  
268    // GC stale cache entries (compaction, /clear, screenToggleId bump). Only
269    // runs when itemKeys identity changes — scrolling doesn't touch keys.
270    // itemRefs self-cleans via ref(null) on unmount.
271    // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
272    useMemo(() => {
273      const live = new Set(itemKeys)
274      let dirty = false
275      for (const k of heightCache.current.keys()) {
276        if (!live.has(k)) {
277          heightCache.current.delete(k)
278          dirty = true
279        }
280      }
281      for (const k of refCache.current.keys()) {
282        if (!live.has(k)) refCache.current.delete(k)
283      }
284      if (dirty) offsetVersionRef.current++
285    }, [itemKeys])
286  
287    // Offsets cached across renders, invalidated by offsetVersion ref bump.
288    // The previous approach allocated new Array(n+1) + ran n Map.get per
289    // render; for n≈27k at key-repeat scroll rate (~11 commits/sec) that's
290    // ~300k lookups/sec on a freshly-allocated array → GC churn + ~2ms/render.
291    // Version bumped by heightCache writers (measureRef, resize-scale, GC).
292    // No setState — the rebuild is read-side-lazy via ref version check during
293    // render (same commit, zero extra schedule). The flicker that forced
294    // inline-recompute came from setState-driven invalidation.
295    const n = itemKeys.length
296    if (
297      offsetsRef.current.version !== offsetVersionRef.current ||
298      offsetsRef.current.n !== n
299    ) {
300      const arr =
301        offsetsRef.current.arr.length >= n + 1
302          ? offsetsRef.current.arr
303          : new Float64Array(n + 1)
304      arr[0] = 0
305      for (let i = 0; i < n; i++) {
306        arr[i + 1] =
307          arr[i]! + (heightCache.current.get(itemKeys[i]!) ?? DEFAULT_ESTIMATE)
308      }
309      offsetsRef.current = { arr, version: offsetVersionRef.current, n }
310    }
311    const offsets = offsetsRef.current.arr
312    const totalHeight = offsets[n]!
313  
314    let start: number
315    let end: number
316  
317    if (frozenRange) {
318      // Column just changed. Keep the pre-resize range to avoid mount churn.
319      // Clamp to n in case messages were removed (/clear, compaction).
320      ;[start, end] = frozenRange
321      start = Math.min(start, n)
322      end = Math.min(end, n)
323    } else if (viewportH === 0 || scrollTop < 0) {
324      // Cold start: ScrollBox hasn't laid out yet. Render the tail — sticky
325      // scroll pins to the bottom on first Ink render, so these are the items
326      // the user actually sees. Any scroll-up after that goes through
327      // scrollBy → subscribe fires → we re-render with real values.
328      start = Math.max(0, n - COLD_START_COUNT)
329      end = n
330    } else {
331      if (isSticky) {
332        // Sticky-scroll fallback. render-node-to-output may have moved scrollTop
333        // without notifying us, so trust "at bottom" over the stale snapshot.
334        // Walk back from the tail until we've covered viewport + overscan.
335        const budget = viewportH + OVERSCAN_ROWS
336        start = n
337        while (start > 0 && totalHeight - offsets[start - 1]! < budget) {
338          start--
339        }
340        end = n
341      } else {
342        // User has scrolled up. Compute start from offsets (estimate-based:
343        // may undershoot which is fine — we just start mounting a bit early).
344        // Then extend end by CUMULATIVE BEST-KNOWN HEIGHT, not estimated
345        // offsets. The invariant is:
346        //   topSpacer + sum(real_heights[start..end]) >= scrollTop + viewportH + overscan
347        // Since topSpacer = offsets[start] ≤ scrollTop - overscan, we need:
348        //   sum(real_heights) >= viewportH + 2*overscan
349        // For unmeasured items, assume PESSIMISTIC_HEIGHT=1 — the smallest a
350        // MessageRow can be. This over-mounts when items are large, but NEVER
351        // leaves the viewport showing empty spacer during fast scroll through
352        // unmeasured territory. Once heights are cached (next render),
353        // coverage is computed with real values and the range tightens.
354        // Advance start past item K only if K is safe to fold into topSpacer
355        // without a visible jump. Two cases are safe:
356        //   (a) K is NOT currently mounted (itemRefs has no entry). Its
357        //       contribution to offsets has ALWAYS been the estimate — the
358        //       spacer already matches what was there. No layout change.
359        //   (b) K is mounted AND its height is cached. offsets[start+1] uses
360        //       the real height, so topSpacer = offsets[start+1] exactly
361        //       equals the Yoga span K occupied. Seamless unmount.
362        // The unsafe case — K is mounted but uncached — is the one-render
363        // window between mount and useLayoutEffect measurement. Keeping K
364        // mounted that one extra render lets the measurement land.
365        // Mount range spans [committed, target] so every drain frame is
366        // covered. Clamp at 0: aggressive wheel-up can push pendingDelta
367        // far past zero (MX Master free-spin), but scrollTop never goes
368        // negative. Without the clamp, effLo drags start to 0 while effHi
369        // stays at the current (high) scrollTop — span exceeds what
370        // MAX_MOUNTED_ITEMS can cover and early drain frames see blank.
371        // listOrigin translates scrollTop (content-wrapper coords) into
372        // list-local coords before comparing against offsets[]. Without
373        // this, pre-list siblings (Logo+notices in Messages.tsx) inflate
374        // scrollTop by their height and start over-advances — eats overscan
375        // first, then visible rows once the inflation exceeds OVERSCAN_ROWS.
376        const listOrigin = listOriginRef.current
377        // Cap the [committed..target] span. When input outpaces render,
378        // pendingDelta grows unbounded → effLo..effHi covers hundreds of
379        // unmounted rows → one commit mounts 194 fresh MessageRows → 3s+
380        // sync block → more input queues → bigger delta next time. Death
381        // spiral. Capping the span bounds fresh mounts per commit; the
382        // clamp (setClampBounds) shows edge-of-mounted during catch-up so
383        // there's no blank screen — scroll reaches target over a few
384        // frames instead of freezing once for seconds.
385        const MAX_SPAN_ROWS = viewportH * 3
386        const rawLo = Math.min(scrollTop, scrollTop + pendingDelta)
387        const rawHi = Math.max(scrollTop, scrollTop + pendingDelta)
388        const span = rawHi - rawLo
389        const clampedLo =
390          span > MAX_SPAN_ROWS
391            ? pendingDelta < 0
392              ? rawHi - MAX_SPAN_ROWS // scrolling up: keep near target (low end)
393              : rawLo // scrolling down: keep near committed
394            : rawLo
395        const clampedHi = clampedLo + Math.min(span, MAX_SPAN_ROWS)
396        const effLo = Math.max(0, clampedLo - listOrigin)
397        const effHi = clampedHi - listOrigin
398        const lo = effLo - OVERSCAN_ROWS
399        // Binary search for start — offsets is monotone-increasing. The
400        // linear while(start++) scan iterated ~27k times per render for the
401        // 27k-msg session (scrolling from bottom, start≈27200). O(log n).
402        {
403          let l = 0
404          let r = n
405          while (l < r) {
406            const m = (l + r) >> 1
407            if (offsets[m + 1]! <= lo) l = m + 1
408            else r = m
409          }
410          start = l
411        }
412        // Guard: don't advance past mounted-but-unmeasured items. During the
413        // one-render window between mount and useLayoutEffect measurement,
414        // unmounting such items would use DEFAULT_ESTIMATE in topSpacer,
415        // which doesn't match their (unknown) real span → flicker. Mounted
416        // items are in [prevStart, prevEnd); scan that, not all n.
417        {
418          const p = prevRangeRef.current
419          if (p && p[0] < start) {
420            for (let i = p[0]; i < Math.min(start, p[1]); i++) {
421              const k = itemKeys[i]!
422              if (itemRefs.current.has(k) && !heightCache.current.has(k)) {
423                start = i
424                break
425              }
426            }
427          }
428        }
429  
430        const needed = viewportH + 2 * OVERSCAN_ROWS
431        const maxEnd = Math.min(n, start + MAX_MOUNTED_ITEMS)
432        let coverage = 0
433        end = start
434        while (
435          end < maxEnd &&
436          (coverage < needed || offsets[end]! < effHi + viewportH + OVERSCAN_ROWS)
437        ) {
438          coverage +=
439            heightCache.current.get(itemKeys[end]!) ?? PESSIMISTIC_HEIGHT
440          end++
441        }
442      }
443      // Same coverage guarantee for the atBottom path (it walked start back
444      // by estimated offsets, which can undershoot if items are small).
445      const needed = viewportH + 2 * OVERSCAN_ROWS
446      const minStart = Math.max(0, end - MAX_MOUNTED_ITEMS)
447      let coverage = 0
448      for (let i = start; i < end; i++) {
449        coverage += heightCache.current.get(itemKeys[i]!) ?? PESSIMISTIC_HEIGHT
450      }
451      while (start > minStart && coverage < needed) {
452        start--
453        coverage +=
454          heightCache.current.get(itemKeys[start]!) ?? PESSIMISTIC_HEIGHT
455      }
456      // Slide cap: limit how many NEW items mount this commit. Scrolling into
457      // a fresh range would otherwise mount 194 items at PESSIMISTIC_HEIGHT=1
458      // coverage — ~290ms React render block. Gates on scroll VELOCITY
459      // (|scrollTop delta since last commit| > 2×viewportH — key-repeat PageUp
460      // moves ~viewportH/2 per press, 3+ presses batched = fast mode). Covers
461      // both scrollBy (pendingDelta) and scrollTo (direct write). Normal
462      // single-PageUp or sticky-break jumps skip this. The clamp
463      // (setClampBounds) holds the viewport at the mounted edge during
464      // catch-up. Only caps range GROWTH; shrinking is unbounded.
465      const prev = prevRangeRef.current
466      const scrollVelocity =
467        Math.abs(scrollTop - lastScrollTopRef.current) + Math.abs(pendingDelta)
468      if (prev && scrollVelocity > viewportH * 2) {
469        const [pS, pE] = prev
470        if (start < pS - SLIDE_STEP) start = pS - SLIDE_STEP
471        if (end > pE + SLIDE_STEP) end = pE + SLIDE_STEP
472        // A large forward jump can push start past the capped end (start
473        // advances via binary search while end is capped at pE + SLIDE_STEP).
474        // Mount SLIDE_STEP items from the new start so the viewport isn't
475        // blank during catch-up.
476        if (start > end) end = Math.min(start + SLIDE_STEP, n)
477      }
478      lastScrollTopRef.current = scrollTop
479    }
480  
481    // Decrement freeze AFTER range is computed. Don't update prevRangeRef
482    // during freeze so both frozen renders reuse the ORIGINAL pre-resize
483    // range (not the clamped-to-n version if messages changed mid-freeze).
484    if (freezeRendersRef.current > 0) {
485      freezeRendersRef.current--
486    } else {
487      prevRangeRef.current = [start, end]
488    }
489    // useDeferredValue lets React render with the OLD range first (cheap —
490    // all memo hits) then transition to the NEW range (expensive — fresh
491    // mounts with marked.lexer + formatToken). The urgent render keeps Ink
492    // painting at input rate; fresh mounts happen in a non-blocking
493    // background render. This is React's native time-slicing: the 62ms
494    // fresh-mount block becomes interruptible. The clamp (setClampBounds)
495    // already handles viewport pinning so there's no visual artifact from
496    // the deferred range lagging briefly behind scrollTop.
497    //
498    // Only defer range GROWTH (start moving earlier / end moving later adds
499    // fresh mounts). Shrinking is cheap (unmount = remove fiber, no parse)
500    // and the deferred value lagging shrink causes stale overscan to stay
501    // mounted one extra tick — harmless but fails tests checking exact
502    // range after measurement-driven tightening.
503    const dStart = useDeferredValue(start)
504    const dEnd = useDeferredValue(end)
505    let effStart = start < dStart ? dStart : start
506    let effEnd = end > dEnd ? dEnd : end
507    // A large jump can make effStart > effEnd (start jumps forward while dEnd
508    // still holds the old range's end). Skip deferral to avoid an inverted
509    // range. Also skip when sticky — scrollToBottom needs the tail mounted
510    // NOW so scrollTop=maxScroll lands on content, not bottomSpacer. The
511    // deferred dEnd (still at old range) would render an incomplete tail,
512    // maxScroll stays at the old content height, and "jump to bottom" stops
513    // short. Sticky snap is a single frame, not continuous scroll — the
514    // time-slicing benefit doesn't apply.
515    if (effStart > effEnd || isSticky) {
516      effStart = start
517      effEnd = end
518    }
519    // Scrolling DOWN (pendingDelta > 0): bypass effEnd deferral so the tail
520    // mounts immediately. Without this, the clamp (based on effEnd) holds
521    // scrollTop short of the real bottom — user scrolls down, hits clampMax,
522    // stops, React catches up effEnd, clampMax widens, but the user already
523    // released. Feels stuck-before-bottom. effStart stays deferred so
524    // scroll-UP keeps time-slicing (older messages parse on mount — the
525    // expensive direction).
526    if (pendingDelta > 0) {
527      effEnd = end
528    }
529    // Final O(viewport) enforcement. The intermediate caps (maxEnd=start+
530    // MAX_MOUNTED_ITEMS, slide cap, deferred-intersection) bound [start,end]
531    // but the deferred+bypass combinations above can let [effStart,effEnd]
532    // slip: e.g. during sustained PageUp when concurrent mode interleaves
533    // dStart updates with effEnd=end bypasses across commits, the effective
534    // window can drift wider than either immediate or deferred alone. On a
535    // 10K-line resumed session this showed as +270MB RSS during PageUp spam
536    // (yoga Node constructor + createWorkInProgress fiber alloc proportional
537    // to scroll distance). Trim the far edge — by viewport position — to keep
538    // fiber count O(viewport) regardless of deferred-value scheduling.
539    if (effEnd - effStart > MAX_MOUNTED_ITEMS) {
540      // Trim side is decided by viewport POSITION, not pendingDelta direction.
541      // pendingDelta drains to 0 between frames while dStart/dEnd lag under
542      // concurrent scheduling; a direction-based trim then flips from "trim
543      // tail" to "trim head" mid-settle, bumping effStart → effTopSpacer →
544      // clampMin → setClampBounds yanks scrollTop down → scrollback vanishes.
545      // Position-based: keep whichever end the viewport is closer to.
546      const mid = (offsets[effStart]! + offsets[effEnd]!) / 2
547      if (scrollTop - listOriginRef.current < mid) {
548        effEnd = effStart + MAX_MOUNTED_ITEMS
549      } else {
550        effStart = effEnd - MAX_MOUNTED_ITEMS
551      }
552    }
553  
554    // Write render-time clamp bounds in a layout effect (not during render —
555    // mutating DOM during React render violates purity). render-node-to-output
556    // clamps scrollTop to this span so burst scrollTo calls that race past
557    // React's async re-render show the EDGE of mounted content (the last/first
558    // visible message) instead of blank spacer.
559    //
560    // Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one.
561    // During fast scroll, immediate [start,end] may already cover the new
562    // scrollTop position, but the children still render at the deferred
563    // (older) range. If clamp uses immediate bounds, the drain-gate in
564    // render-node-to-output sees scrollTop within clamp → drains past the
565    // deferred children's span → viewport lands in spacer → white flash.
566    // Using effStart/effEnd keeps clamp synced with what's actually mounted.
567    //
568    // Skip clamp when sticky — render-node-to-output pins scrollTop=maxScroll
569    // authoritatively. Clamping during cold-start/load causes flicker: first
570    // render uses estimate-based offsets, clamp set, sticky-follow moves
571    // scrollTop, measurement fires, offsets rebuild with real heights, second
572    // render's clamp differs → scrollTop clamp-adjusts → content shifts.
573    const listOrigin = listOriginRef.current
574    const effTopSpacer = offsets[effStart]!
575    // At effStart=0 there's no unmounted content above — the clamp must allow
576    // scrolling past listOrigin to see pre-list content (logo, header) that
577    // sits in the ScrollBox but outside VirtualMessageList. Only clamp when
578    // the topSpacer is nonzero (there ARE unmounted items above).
579    const clampMin = effStart === 0 ? 0 : effTopSpacer + listOrigin
580    // At effEnd=n there's no bottomSpacer — nothing to avoid racing past. Using
581    // offsets[n] here would bake in heightCache (one render behind Yoga), and
582    // when the tail item is STREAMING its cached height lags its real height by
583    // however much arrived since last measure. Sticky-break then clamps
584    // scrollTop below the real max, pushing the streaming text off-viewport
585    // (the "scrolled up, response disappeared" bug). Infinity = unbounded:
586    // render-node-to-output's own Math.min(cur, maxScroll) governs instead.
587    const clampMax =
588      effEnd === n
589        ? Infinity
590        : Math.max(effTopSpacer, offsets[effEnd]! - viewportH) + listOrigin
591    useLayoutEffect(() => {
592      if (isSticky) {
593        scrollRef.current?.setClampBounds(undefined, undefined)
594      } else {
595        scrollRef.current?.setClampBounds(clampMin, clampMax)
596      }
597    })
598  
599    // Measure heights from the PREVIOUS Ink render. Runs every commit (no
600    // deps) because Yoga recomputes layout without React knowing. yogaNode
601    // heights for items mounted ≥1 frame ago are valid; brand-new items
602    // haven't been laid out yet (that happens in resetAfterCommit → onRender,
603    // after this effect).
604    //
605    // Distinguishing "h=0: Yoga hasn't run" (transient, skip) from "h=0:
606    // MessageRow rendered null" (permanent, cache it): getComputedWidth() > 0
607    // proves Yoga HAS laid out this node (width comes from the container,
608    // always non-zero for a Box in a column). If width is set and height is
609    // 0, the item is genuinely empty — cache 0 so the start-advance gate
610    // doesn't block on it forever. Without this, a null-rendering message
611    // at the start boundary freezes the range (seen as blank viewport when
612    // scrolling down after scrolling up).
613    //
614    // NO setState. A setState here would schedule a second commit with
615    // shifted offsets, and since Ink writes stdout on every commit
616    // (reconciler.resetAfterCommit → onRender), that's two writes with
617    // different spacer heights → visible flicker. Heights propagate to
618    // offsets on the next natural render. One-frame lag, absorbed by overscan.
619    useLayoutEffect(() => {
620      const spacerYoga = spacerRef.current?.yogaNode
621      if (spacerYoga && spacerYoga.getComputedWidth() > 0) {
622        listOriginRef.current = spacerYoga.getComputedTop()
623      }
624      if (skipMeasurementRef.current) {
625        skipMeasurementRef.current = false
626        return
627      }
628      let anyChanged = false
629      for (const [key, el] of itemRefs.current) {
630        const yoga = el.yogaNode
631        if (!yoga) continue
632        const h = yoga.getComputedHeight()
633        const prev = heightCache.current.get(key)
634        if (h > 0) {
635          if (prev !== h) {
636            heightCache.current.set(key, h)
637            anyChanged = true
638          }
639        } else if (yoga.getComputedWidth() > 0 && prev !== 0) {
640          heightCache.current.set(key, 0)
641          anyChanged = true
642        }
643      }
644      if (anyChanged) offsetVersionRef.current++
645    })
646  
647    // Stable per-key callback refs. React's ref-swap dance (old(null) then
648    // new(el)) is a no-op when the callback is identity-stable, avoiding
649    // itemRefs churn on every render. GC'd alongside heightCache above.
650    // The ref(null) path also captures height at unmount — the yogaNode is
651    // still valid then (reconciler calls ref(null) before removeChild →
652    // freeRecursive), so we get the final measurement before WASM release.
653    const measureRef = useCallback((key: string) => {
654      let fn = refCache.current.get(key)
655      if (!fn) {
656        fn = (el: DOMElement | null) => {
657          if (el) {
658            itemRefs.current.set(key, el)
659          } else {
660            const yoga = itemRefs.current.get(key)?.yogaNode
661            if (yoga && !skipMeasurementRef.current) {
662              const h = yoga.getComputedHeight()
663              if (
664                (h > 0 || yoga.getComputedWidth() > 0) &&
665                heightCache.current.get(key) !== h
666              ) {
667                heightCache.current.set(key, h)
668                offsetVersionRef.current++
669              }
670            }
671            itemRefs.current.delete(key)
672          }
673        }
674        refCache.current.set(key, fn)
675      }
676      return fn
677    }, [])
678  
679    const getItemTop = useCallback(
680      (index: number) => {
681        const yoga = itemRefs.current.get(itemKeys[index]!)?.yogaNode
682        if (!yoga || yoga.getComputedWidth() === 0) return -1
683        return yoga.getComputedTop()
684      },
685      [itemKeys],
686    )
687  
688    const getItemElement = useCallback(
689      (index: number) => itemRefs.current.get(itemKeys[index]!) ?? null,
690      [itemKeys],
691    )
692    const getItemHeight = useCallback(
693      (index: number) => heightCache.current.get(itemKeys[index]!),
694      [itemKeys],
695    )
696    const scrollToIndex = useCallback(
697      (i: number) => {
698        // offsetsRef.current holds latest cached offsets (event handlers run
699        // between renders; a render-time closure would be stale).
700        const o = offsetsRef.current
701        if (i < 0 || i >= o.n) return
702        scrollRef.current?.scrollTo(o.arr[i]! + listOriginRef.current)
703      },
704      [scrollRef],
705    )
706  
707    const effBottomSpacer = totalHeight - offsets[effEnd]!
708  
709    return {
710      range: [effStart, effEnd],
711      topSpacer: effTopSpacer,
712      bottomSpacer: effBottomSpacer,
713      measureRef,
714      spacerRef,
715      offsets,
716      getItemTop,
717      getItemElement,
718      getItemHeight,
719      scrollToIndex,
720    }
721  }