( 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, )
| 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++ |
no test coverage detected