source dump of claude code
at main 721 lines 35 kB view raw
1import type { RefObject } from 'react' 2import { 3 useCallback, 4 useDeferredValue, 5 useLayoutEffect, 6 useMemo, 7 useRef, 8 useSyncExternalStore, 9} from 'react' 10import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' 11import 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 */ 19const 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 */ 24const OVERSCAN_ROWS = 80 25/** Items rendered before the ScrollBox has laid out (viewportHeight=0). */ 26const 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 */ 37const 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 */ 45const PESSIMISTIC_HEIGHT = 1 46/** Cap on mounted items to bound fiber allocation even in degenerate cases. */ 47const 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 */ 57const SLIDE_STEP = 25 58 59const NOOP_UNSUB = () => {} 60 61export 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 */ 142export 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}