( scrollRef: RefObject<ScrollBoxHandle | null>, 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, estimateItemHeight?: (index: number) => number | undefined, )
| 511 | * the last N items regardless of what scrollTop claims. |
| 512 | */ |
| 513 | export function useVirtualScroll( |
| 514 | scrollRef: RefObject<ScrollBoxHandle | null>, |
| 515 | itemKeys: readonly string[], |
| 516 | /** |
| 517 | * Terminal column count. On change, cached heights are stale (text |
| 518 | * rewraps) — SCALED by oldCols/newCols rather than cleared. Clearing |
| 519 | * made the pessimistic coverage back-walk mount ~190 items (every |
| 520 | * uncached item → PESSIMISTIC_HEIGHT=1 → walk 190 to reach |
| 521 | * viewport+2×overscan). Each fresh mount runs marked.lexer + syntax |
| 522 | * highlighting ≈ 3ms; ~600ms React reconcile on first resize with a |
| 523 | * long conversation. Scaling keeps heightCache populated → back-walk |
| 524 | * uses real-ish heights → mount range stays tight. Scaled estimates |
| 525 | * are overwritten by real Yoga heights on next useLayoutEffect. |
| 526 | * |
| 527 | * Scaled heights are close enough that the black-screen-on-widen bug |
| 528 | * (inflated pre-resize offsets overshoot post-resize scrollTop → end |
| 529 | * loop stops short of tail) doesn't trigger: ratio<1 on widen scales |
| 530 | * heights DOWN, keeping offsets roughly aligned with post-resize Yoga. |
| 531 | */ |
| 532 | columns: number, |
| 533 | estimateItemHeight?: (index: number) => number | undefined, |
| 534 | ): VirtualScrollResult { |
| 535 | const terminalSize = useContext(TerminalSizeContext) |
| 536 | const heightCache = useRef(new Map<string, number>()) |
| 537 | // Bump whenever heightCache mutates so offsets rebuild on next read. Ref |
| 538 | // (not state) — checked during render phase, zero extra commits. |
| 539 | const offsetVersionRef = useRef(0) |
| 540 | // scrollTop at last commit, for detecting fast-scroll mode (slide cap gate). |
| 541 | const lastScrollTopRef = useRef(0) |
| 542 | const offsetsRef = useRef<VirtualScrollOffsetsState>({ |
| 543 | arr: new Float64Array(0), |
| 544 | version: -1, |
| 545 | n: -1, |
| 546 | }) |
| 547 | const itemRefs = useRef(new Map<string, DOMElement>()) |
| 548 | const refCache = useRef(new Map<string, (el: DOMElement | null) => void>()) |
| 549 | // Inline ref-compare: must run before offsets is computed below. The |
| 550 | // skip-flag guards useLayoutEffect from re-populating heightCache with |
| 551 | // PRE-resize Yoga heights (useLayoutEffect reads Yoga from the frame |
| 552 | // BEFORE this render's calculateLayout — the one that had the old width). |
| 553 | // Next render's useLayoutEffect reads post-resize Yoga → correct. |
| 554 | const prevColumns = useRef(columns) |
| 555 | const skipMeasurementRef = useRef(false) |
| 556 | // Freeze the mount range for the resize-settling cycle. Already-mounted |
| 557 | // items have warm useMemo (marked.lexer, highlighting); recomputing range |
| 558 | // from scaled/pessimistic estimates causes mount/unmount churn (~3ms per |
| 559 | // fresh mount = ~150ms visible as a second flash). The pre-resize range is |
| 560 | // as good as any — items visible at old width are what the user wants at |
| 561 | // new width. Frozen for 2 renders: render #1 has skipMeasurement (Yoga |
| 562 | // still pre-resize), render #2's useLayoutEffect reads post-resize Yoga |
| 563 | // into heightCache. Render #3 has accurate heights → normal recompute. |
| 564 | const prevRangeRef = useRef<readonly [number, number] | null>(null) |
| 565 | const freezeRendersRef = useRef(0) |
| 566 | const stableClampBoundsRef = useRef<VirtualScrollClampBounds>({ |
| 567 | clampMin: undefined, |
| 568 | clampMax: undefined, |
| 569 | }) |
| 570 | const reusedStableClampRef = useRef(false) |
no test coverage detected