import type { RefObject } from 'react' import { useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from 'react' import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' import type { DOMElement } from '../ink/dom.js' /** * Estimated height (rows) for items not yet measured. Intentionally LOW: * overestimating causes blank space (we stop mounting too early and the * viewport bottom shows empty spacer), while underestimating just mounts * a few extra items into overscan. The asymmetry means we'd rather err low. */ const DEFAULT_ESTIMATE = 3 /** * Extra rows rendered above and below the viewport. Generous because real * heights can be 10x the estimate for long tool results. */ const OVERSCAN_ROWS = 80 /** Items rendered before the ScrollBox has laid out (viewportHeight=0). */ const COLD_START_COUNT = 30 /** * scrollTop quantization for the useSyncExternalStore snapshot. Without * this, every wheel tick (3-5 per notch) triggers a full React commit + * Yoga calculateLayout() + Ink diff cycle — the CPU spike. Visual scroll * stays smooth regardless: ScrollBox.forceRender fires on every scrollBy * and Ink reads the REAL scrollTop from the DOM node, independent of what * React thinks. React only needs to re-render when the mounted range must * shift; half of OVERSCAN_ROWS is the tightest safe bin (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. * A MessageRow can be as small as 1 row (single-line tool call). Using 1 * here guarantees the mounted span physically reaches the viewport bottom * regardless of how small items actually are — at the cost of over-mounting * when items are larger (which is fine, overscan absorbs it). */ const PESSIMISTIC_HEIGHT = 1 /** Cap on mounted items to bound fiber allocation even in degenerate cases. */ const MAX_MOUNTED_ITEMS = 300 /** * Max NEW items to mount in a single commit. Scrolling into a fresh range * with PESSIMISTIC_HEIGHT=1 would mount 194 items at once (OVERSCAN_ROWS*2+ * viewportH = 194); each fresh MessageRow render costs ~1.5ms (marked lexer * + formatToken + ~11 createInstance) = ~290ms sync block. Sliding the range * toward the target over multiple commits keeps per-commit mount cost * bounded. The render-time clamp (scrollClampMin/Max) holds the viewport at * the edge of mounted content so there's no blank during catch-up. */ const SLIDE_STEP = 25 const NOOP_UNSUB = () => {} 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 * (first child of the virtualized region, so its top = cumulative * height of everything rendered before the list in the ScrollBox). * Drift-free: no subtraction of offsets, no dependence on item * heights that change between renders (tmux resize). */ spacerRef: RefObject /** * Cumulative y-offset of each item in list-wrapper coords (NOT scrollbox * coords — logo/siblings before this list shift the origin). * offsets[i] = rows above item i; offsets[n] = totalHeight. * Recomputed every render — don't memo on identity. */ offsets: ArrayLike /** * Read Yoga computedTop for item at index. Returns -1 if the item isn't * mounted or hasn't been laid out. Item Boxes are direct Yoga children * of the ScrollBox content wrapper (fragments collapse in the Ink DOM), * so this is content-wrapper-relative — same coordinate space as * scrollTop. Yoga layout is scroll-independent (translation happens * later in renderNodeToOutput), so positions stay valid across scrolls * without waiting for Ink to re-render. StickyTracker walks the mount * range with this to find the viewport boundary at per-scroll-tick * granularity (finer than the 40-row quantum this hook re-renders at). */ getItemTop: (index: number) => number /** * Get the mounted DOMElement for item at index, or null. For * ScrollBox.scrollToElement — anchoring by element ref defers the * Yoga-position read to render time (deterministic; no throttle race). */ 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. Sets scrollTop = * offsets[i] + listOrigin. The range logic finds start from * scrollTop vs offsets[] — BOTH use the same offsets value, so they * agree by construction regardless of whether offsets[i] is the * "true" position. Item i mounts; its screen position may be off by * a few-dozen rows (overscan-worth of estimate drift), but it's in * the DOM. Follow with getItemTop(i) for the precise position. */ scrollToIndex: (i: number) => void } /** * React-level virtualization for items inside a ScrollBox. * * The ScrollBox already does Ink-output-level viewport culling * (render-node-to-output.ts:617 skips children outside the visible window), * but all React fibers + Yoga nodes are still allocated. At ~250 KB RSS per * MessageRow, a 1000-message session costs ~250 MB of grow-only memory * (Ink screen buffer, WASM linear memory, JSC page retention all grow-only). * * This hook mounts only items in viewport + overscan. Spacer boxes hold the * scroll height constant for the rest at O(1) fiber cost each. * * Height estimation: fixed DEFAULT_ESTIMATE for unmeasured items, replaced * by real Yoga heights after first layout. No scroll anchoring — overscan * absorbs estimate errors. If drift is noticeable in practice, anchoring * (scrollBy(delta) when topSpacer changes) is a straightforward followup. * * stickyScroll caveat: render-node-to-output.ts:450 sets scrollTop=maxScroll * during Ink's render phase, which does NOT fire ScrollBox.subscribe. The * at-bottom check below handles this — when pinned to the bottom, we render * the last N items regardless of what scrollTop claims. */ export function useVirtualScroll( scrollRef: RefObject, itemKeys: readonly string[], /** * Terminal column count. On change, cached heights are stale (text * rewraps) — SCALED by oldCols/newCols rather than cleared. Clearing * made the pessimistic coverage back-walk mount ~190 items (every * uncached item → PESSIMISTIC_HEIGHT=1 → walk 190 to reach * viewport+2×overscan). Each fresh mount runs marked.lexer + syntax * highlighting ≈ 3ms; ~600ms React reconcile on first resize with a * long conversation. Scaling keeps heightCache populated → back-walk * uses real-ish heights → mount range stays tight. Scaled estimates * are overwritten by real Yoga heights on next useLayoutEffect. * * Scaled heights are close enough that the black-screen-on-widen bug * (inflated pre-resize offsets overshoot post-resize scrollTop → end * loop stops short of tail) doesn't trigger: ratio<1 on widen scales * heights DOWN, keeping offsets roughly aligned with post-resize Yoga. */ columns: number, ): VirtualScrollResult { const heightCache = useRef(new Map()) // Bump whenever heightCache mutates so offsets rebuild on next read. Ref // (not state) — checked during render phase, zero extra commits. const offsetVersionRef = useRef(0) // scrollTop at last commit, for detecting fast-scroll mode (slide cap gate). 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>()) // Inline ref-compare: must run before offsets is computed below. The // skip-flag guards useLayoutEffect from re-populating heightCache with // PRE-resize Yoga heights (useLayoutEffect reads Yoga from the frame // BEFORE this render's calculateLayout — the one that had the old width). // Next render's useLayoutEffect reads post-resize Yoga → correct. const prevColumns = useRef(columns) const skipMeasurementRef = useRef(false) // Freeze the mount range for the resize-settling cycle. Already-mounted // items have warm useMemo (marked.lexer, highlighting); recomputing range // from scaled/pessimistic estimates causes mount/unmount churn (~3ms per // fresh mount = ~150ms visible as a second flash). The pre-resize range is // as good as any — items visible at old width are what the user wants at // new width. Frozen for 2 renders: render #1 has skipMeasurement (Yoga // still pre-resize), render #2's useLayoutEffect reads post-resize Yoga // into heightCache. Render #3 has accurate heights → normal recompute. const prevRangeRef = useRef(null) const freezeRendersRef = useRef(0) 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 // List origin in content-wrapper coords. scrollTop is content-wrapper- // relative, but offsets[] are list-local (0 = first virtualized item). // Siblings that render BEFORE this list inside the ScrollBox — Logo, // StatusNotices, truncation divider in Messages.tsx — shift item Yoga // positions by their cumulative height. Without subtracting this, the // non-sticky branch's effLo/effHi are inflated and start advances past // items that are actually in view (blank viewport on click/scroll when // sticky breaks while scrollTop is near max). Read from the topSpacer's // Yoga computedTop — it's the first child of the virtualized region, so // its top IS listOrigin. No subtraction of offsets → no drift when item // heights change between renders (tmux resize: columns change → re-wrap // → heights shrink → the old item-sample subtraction went negative → // effLo inflated → black screen). One-frame lag like heightCache. const listOriginRef = useRef(0) const spacerRef = useRef(null) // useSyncExternalStore ties re-renders to imperative scroll. Snapshot is // scrollTop QUANTIZED to SCROLL_QUANTUM bins — Object.is sees no change // for small scrolls (most wheel ticks), so React skips the commit + Yoga // + Ink cycle entirely until the accumulated delta crosses a bin. // Sticky is folded into the snapshot (sign bit) so sticky→broken also // triggers: scrollToBottom sets sticky=true without moving scrollTop // (Ink moves it later), and the first scrollBy after may land in the // same bin. NaN sentinel = ref not attached. const subscribe = useCallback( (listener: () => void) => scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, [scrollRef], ) useSyncExternalStore(subscribe, () => { const s = scrollRef.current if (!s) return NaN // Snapshot uses the TARGET (scrollTop + pendingDelta), not committed // scrollTop. scrollBy only mutates pendingDelta (renderer drains it // across frames); committed scrollTop lags. Using target means // notify() on scrollBy actually changes the snapshot → React remounts // children for the destination before Ink's drain frames need them. const target = s.getScrollTop() + s.getPendingDelta() const bin = Math.floor(target / SCROLL_QUANTUM) return s.isSticky() ? ~bin : bin }) // Read the REAL committed scrollTop (not quantized) for range math — // quantization is only the re-render gate, not the position. const scrollTop = scrollRef.current?.getScrollTop() ?? -1 // Range must span BOTH committed scrollTop (where Ink is rendering NOW) // and target (where pending will drain to). During drain, intermediate // frames render at scrollTops between the two — if we only mount for // the target, those frames find no children (blank rows). const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0 const viewportH = scrollRef.current?.getViewportHeight() ?? 0 // True means the ScrollBox is pinned to the bottom. This is the ONLY // stable "at bottom" signal: scrollTop/scrollHeight both reflect the // PREVIOUS render's layout, which depends on what WE rendered (topSpacer + // items), creating a feedback loop (range → layout → atBottom → range). // stickyScroll is set by user action (scrollToBottom/scrollBy), the initial // attribute, AND by render-node-to-output when its positional follow fires // (scrollTop>=prevMax → pin to new max → set flag). The renderer write is // feedback-safe: it only flips false→true, only when already at the // positional bottom, and the flag being true here just means "tail-walk, // clear clamp" — the same behavior as if we'd read scrollTop==maxScroll // directly, minus the instability. Default true: before the ref attaches, // assume bottom (sticky will pin us there on first Ink render). const isSticky = scrollRef.current?.isSticky() ?? true // GC stale cache entries (compaction, /clear, screenToggleId bump). Only // runs when itemKeys identity changes — scrolling doesn't touch keys. // itemRefs self-cleans via ref(null) on unmount. // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable 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]) // Offsets cached across renders, invalidated by offsetVersion ref bump. // The previous approach allocated new Array(n+1) + ran n Map.get per // render; for n≈27k at key-repeat scroll rate (~11 commits/sec) that's // ~300k lookups/sec on a freshly-allocated array → GC churn + ~2ms/render. // Version bumped by heightCache writers (measureRef, resize-scale, GC). // No setState — the rebuild is read-side-lazy via ref version check during // render (same commit, zero extra schedule). The flicker that forced // inline-recompute came from setState-driven invalidation. 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) { // Column just changed. Keep the pre-resize range to avoid mount churn. // Clamp to n in case messages were removed (/clear, compaction). ;[start, end] = frozenRange start = Math.min(start, n) end = Math.min(end, n) } else if (viewportH === 0 || scrollTop < 0) { // Cold start: ScrollBox hasn't laid out yet. Render the tail — sticky // scroll pins to the bottom on first Ink render, so these are the items // the user actually sees. Any scroll-up after that goes through // scrollBy → subscribe fires → we re-render with real values. start = Math.max(0, n - COLD_START_COUNT) end = n } else { if (isSticky) { // Sticky-scroll fallback. render-node-to-output may have moved scrollTop // without notifying us, so trust "at bottom" over the stale snapshot. // Walk back from the tail until we've covered viewport + overscan. const budget = viewportH + OVERSCAN_ROWS start = n while (start > 0 && totalHeight - offsets[start - 1]! < budget) { start-- } end = n } else { // User has scrolled up. Compute start from offsets (estimate-based: // may undershoot which is fine — we just start mounting a bit early). // Then extend end by CUMULATIVE BEST-KNOWN HEIGHT, not estimated // offsets. The invariant is: // topSpacer + sum(real_heights[start..end]) >= scrollTop + viewportH + overscan // Since topSpacer = offsets[start] ≤ scrollTop - overscan, we need: // sum(real_heights) >= viewportH + 2*overscan // For unmeasured items, assume PESSIMISTIC_HEIGHT=1 — the smallest a // MessageRow can be. This over-mounts when items are large, but NEVER // leaves the viewport showing empty spacer during fast scroll through // unmeasured territory. Once heights are cached (next render), // coverage is computed with real values and the range tightens. // Advance start past item K only if K is safe to fold into topSpacer // without a visible jump. Two cases are safe: // (a) K is NOT currently mounted (itemRefs has no entry). Its // contribution to offsets has ALWAYS been the estimate — the // spacer already matches what was there. No layout change. // (b) K is mounted AND its height is cached. offsets[start+1] uses // the real height, so topSpacer = offsets[start+1] exactly // equals the Yoga span K occupied. Seamless unmount. // The unsafe case — K is mounted but uncached — is the one-render // window between mount and useLayoutEffect measurement. Keeping K // mounted that one extra render lets the measurement land. // Mount range spans [committed, target] so every drain frame is // covered. Clamp at 0: aggressive wheel-up can push pendingDelta // far past zero (MX Master free-spin), but scrollTop never goes // negative. Without the clamp, effLo drags start to 0 while effHi // stays at the current (high) scrollTop — span exceeds what // MAX_MOUNTED_ITEMS can cover and early drain frames see blank. // listOrigin translates scrollTop (content-wrapper coords) into // list-local coords before comparing against offsets[]. Without // this, pre-list siblings (Logo+notices in Messages.tsx) inflate // scrollTop by their height and start over-advances — eats overscan // first, then visible rows once the inflation exceeds OVERSCAN_ROWS. const listOrigin = listOriginRef.current // Cap the [committed..target] span. When input outpaces render, // pendingDelta grows unbounded → effLo..effHi covers hundreds of // unmounted rows → one commit mounts 194 fresh MessageRows → 3s+ // sync block → more input queues → bigger delta next time. Death // spiral. Capping the span bounds fresh mounts per commit; the // clamp (setClampBounds) shows edge-of-mounted during catch-up so // there's no blank screen — scroll reaches target over a few // frames instead of freezing once for seconds. 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 // scrolling up: keep near target (low end) : rawLo // scrolling down: keep near committed : 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 — offsets is monotone-increasing. The // linear while(start++) scan iterated ~27k times per render for the // 27k-msg session (scrolling from bottom, start≈27200). O(log n). { 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. During the // one-render window between mount and useLayoutEffect measurement, // unmounting such items would use DEFAULT_ESTIMATE in topSpacer, // which doesn't match their (unknown) real span → flicker. Mounted // items are in [prevStart, prevEnd); scan that, not all n. { 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++ } } // Same coverage guarantee for the atBottom path (it walked start back // by estimated offsets, which can undershoot if items are small). 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. Scrolling into // a fresh range would otherwise mount 194 items at PESSIMISTIC_HEIGHT=1 // coverage — ~290ms React render block. Gates on scroll VELOCITY // (|scrollTop delta since last commit| > 2×viewportH — key-repeat PageUp // moves ~viewportH/2 per press, 3+ presses batched = fast mode). Covers // both scrollBy (pendingDelta) and scrollTo (direct write). Normal // single-PageUp or sticky-break jumps skip this. The clamp // (setClampBounds) holds the viewport at the mounted edge during // catch-up. Only caps range GROWTH; shrinking is unbounded. 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 // A large forward jump can push start past the capped end (start // advances via binary search while end is capped at pE + SLIDE_STEP). // Mount SLIDE_STEP items from the new start so the viewport isn't // blank during catch-up. if (start > end) end = Math.min(start + SLIDE_STEP, n) } lastScrollTopRef.current = scrollTop } // Decrement freeze AFTER range is computed. Don't update prevRangeRef // during freeze so both frozen renders reuse the ORIGINAL pre-resize // range (not the clamped-to-n version if messages changed mid-freeze). if (freezeRendersRef.current > 0) { freezeRendersRef.current-- } else { prevRangeRef.current = [start, end] } // useDeferredValue lets React render with the OLD range first (cheap — // all memo hits) then transition to the NEW range (expensive — fresh // mounts with marked.lexer + formatToken). The urgent render keeps Ink // painting at input rate; fresh mounts happen in a non-blocking // background render. This is React's native time-slicing: the 62ms // fresh-mount block becomes interruptible. The clamp (setClampBounds) // already handles viewport pinning so there's no visual artifact from // the deferred range lagging briefly behind scrollTop. // // Only defer range GROWTH (start moving earlier / end moving later adds // fresh mounts). Shrinking is cheap (unmount = remove fiber, no parse) // and the deferred value lagging shrink causes stale overscan to stay // mounted one extra tick — harmless but fails tests checking exact // range after measurement-driven tightening. const dStart = useDeferredValue(start) const dEnd = useDeferredValue(end) let effStart = start < dStart ? dStart : start let effEnd = end > dEnd ? dEnd : end // A large jump can make effStart > effEnd (start jumps forward while dEnd // still holds the old range's end). Skip deferral to avoid an inverted // range. Also skip when sticky — scrollToBottom needs the tail mounted // NOW so scrollTop=maxScroll lands on content, not bottomSpacer. The // deferred dEnd (still at old range) would render an incomplete tail, // maxScroll stays at the old content height, and "jump to bottom" stops // short. Sticky snap is a single frame, not continuous scroll — the // time-slicing benefit doesn't apply. if (effStart > effEnd || isSticky) { effStart = start effEnd = end } // Scrolling DOWN (pendingDelta > 0): bypass effEnd deferral so the tail // mounts immediately. Without this, the clamp (based on effEnd) holds // scrollTop short of the real bottom — user scrolls down, hits clampMax, // stops, React catches up effEnd, clampMax widens, but the user already // released. Feels stuck-before-bottom. effStart stays deferred so // scroll-UP keeps time-slicing (older messages parse on mount — the // expensive direction). if (pendingDelta > 0) { effEnd = end } // Final O(viewport) enforcement. The intermediate caps (maxEnd=start+ // MAX_MOUNTED_ITEMS, slide cap, deferred-intersection) bound [start,end] // but the deferred+bypass combinations above can let [effStart,effEnd] // slip: e.g. during sustained PageUp when concurrent mode interleaves // dStart updates with effEnd=end bypasses across commits, the effective // window can drift wider than either immediate or deferred alone. On a // 10K-line resumed session this showed as +270MB RSS during PageUp spam // (yoga Node constructor + createWorkInProgress fiber alloc proportional // to scroll distance). Trim the far edge — by viewport position — to keep // fiber count O(viewport) regardless of deferred-value scheduling. if (effEnd - effStart > MAX_MOUNTED_ITEMS) { // Trim side is decided by viewport POSITION, not pendingDelta direction. // pendingDelta drains to 0 between frames while dStart/dEnd lag under // concurrent scheduling; a direction-based trim then flips from "trim // tail" to "trim head" mid-settle, bumping effStart → effTopSpacer → // clampMin → setClampBounds yanks scrollTop down → scrollback vanishes. // Position-based: keep whichever end the viewport is closer to. const mid = (offsets[effStart]! + offsets[effEnd]!) / 2 if (scrollTop - listOriginRef.current < mid) { effEnd = effStart + MAX_MOUNTED_ITEMS } else { effStart = effEnd - MAX_MOUNTED_ITEMS } } // Write render-time clamp bounds in a layout effect (not during render — // mutating DOM during React render violates purity). render-node-to-output // clamps scrollTop to this span so burst scrollTo calls that race past // React's async re-render show the EDGE of mounted content (the last/first // visible message) instead of blank spacer. // // Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one. // During fast scroll, immediate [start,end] may already cover the new // scrollTop position, but the children still render at the deferred // (older) range. If clamp uses immediate bounds, the drain-gate in // render-node-to-output sees scrollTop within clamp → drains past the // deferred children's span → viewport lands in spacer → white flash. // Using effStart/effEnd keeps clamp synced with what's actually mounted. // // Skip clamp when sticky — render-node-to-output pins scrollTop=maxScroll // authoritatively. Clamping during cold-start/load causes flicker: first // render uses estimate-based offsets, clamp set, sticky-follow moves // scrollTop, measurement fires, offsets rebuild with real heights, second // render's clamp differs → scrollTop clamp-adjusts → content shifts. const listOrigin = listOriginRef.current const effTopSpacer = offsets[effStart]! // At effStart=0 there's no unmounted content above — the clamp must allow // scrolling past listOrigin to see pre-list content (logo, header) that // sits in the ScrollBox but outside VirtualMessageList. Only clamp when // the topSpacer is nonzero (there ARE unmounted items above). const clampMin = effStart === 0 ? 0 : effTopSpacer + listOrigin // At effEnd=n there's no bottomSpacer — nothing to avoid racing past. Using // offsets[n] here would bake in heightCache (one render behind Yoga), and // when the tail item is STREAMING its cached height lags its real height by // however much arrived since last measure. Sticky-break then clamps // scrollTop below the real max, pushing the streaming text off-viewport // (the "scrolled up, response disappeared" bug). Infinity = unbounded: // render-node-to-output's own Math.min(cur, maxScroll) governs instead. 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. Runs every commit (no // deps) because Yoga recomputes layout without React knowing. yogaNode // heights for items mounted ≥1 frame ago are valid; brand-new items // haven't been laid out yet (that happens in resetAfterCommit → onRender, // after this effect). // // Distinguishing "h=0: Yoga hasn't run" (transient, skip) from "h=0: // MessageRow rendered null" (permanent, cache it): getComputedWidth() > 0 // proves Yoga HAS laid out this node (width comes from the container, // always non-zero for a Box in a column). If width is set and height is // 0, the item is genuinely empty — cache 0 so the start-advance gate // doesn't block on it forever. Without this, a null-rendering message // at the start boundary freezes the range (seen as blank viewport when // scrolling down after scrolling up). // // NO setState. A setState here would schedule a second commit with // shifted offsets, and since Ink writes stdout on every commit // (reconciler.resetAfterCommit → onRender), that's two writes with // different spacer heights → visible flicker. Heights propagate to // offsets on the next natural render. One-frame lag, absorbed by overscan. 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. React's ref-swap dance (old(null) then // new(el)) is a no-op when the callback is identity-stable, avoiding // itemRefs churn on every render. GC'd alongside heightCache above. // The ref(null) path also captures height at unmount — the yogaNode is // still valid then (reconciler calls ref(null) before removeChild → // freeRecursive), so we get the final measurement before WASM release. 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) => { // offsetsRef.current holds latest cached offsets (event handlers run // between renders; a render-time closure would be stale). 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, } }