@react-x11/components 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,570 @@
1
+ // The virtualization window: which rows are worth building, given where the
2
+ // viewport is and what it is doing.
3
+ //
4
+ // **Shared between `<Tree>` and `<Table>`** — the fourth piece both
5
+ // virtualizers stand on, beside `./heights.ts`, `./timers.ts` and
6
+ // `./scroll.ts`, and promoted for the same reason: the slice arithmetic, the
7
+ // viewport bookkeeping and the offset re-read after layout were line-for-line
8
+ // identical in both, and the scroll-responsiveness work lands in one place
9
+ // instead of two. See the header of `./heights.ts` for why this directory is
10
+ // internal rather than a shared module with a subpath.
11
+ //
12
+ // What a scroll actually costs on this renderer is the reason this file is
13
+ // more than `indexAt(top) ± overscan`. A wheel notch scrolls the pane and
14
+ // repaints the exposed strip **synchronously, before React runs** — core
15
+ // blits on the event's own frame — so whatever is mounted in that strip is
16
+ // what the user sees. Rows built by the re-render land a frame later at the
17
+ // earliest. The only scroll with no blank frame at all is one that lands
18
+ // inside rows that were already built, which is what the window is for:
19
+ //
20
+ // - **Idle prefetch.** While nothing is scrolling, the window grows
21
+ // chunk-by-chunk beyond the overscan, up to `prefetch` rows each side —
22
+ // prep work done while nobody is watching, so the next scroll lands on
23
+ // rows that are already there. Grown in small steps so no single commit
24
+ // is felt, and paused the moment a scroll arrives.
25
+ // - **Velocity lead.** While scrolling, the window extends in the
26
+ // direction of travel by the distance the next few frames will cover, so
27
+ // the rows being built are the ones about to be exposed rather than ones
28
+ // already behind the viewport.
29
+ // - **Hysteresis.** Rows already built stay in the window until the budget
30
+ // forces them out, trailing side first — a direction reversal lands on
31
+ // rows still mounted instead of rebuilding them. The budget caps what a
32
+ // render can be asked to carry: the on-screen slice plus `prefetch` rows
33
+ // each side.
34
+ // - **Skeletons.** A scroll that outruns everything — a thumb dragged
35
+ // across the list, a flick past the band — floods the window with rows
36
+ // no single render can build in time. Those render as *skeletons*: the
37
+ // row box at its indexed height, none of its content. A skeleton commit
38
+ // is cheap, so it lands frames sooner than the full rows would, and
39
+ // what blits in reads as rows arriving rather than a void. Upgrades
40
+ // follow viewport-first, a budget per render, until none remain.
41
+ //
42
+ // A row outside the viewport costs its share of layout and nothing on the
43
+ // wire — the pane clips it — so the band is cheap to hold and pays for
44
+ // itself on the first notch that lands inside it.
45
+ import { useCallback, useEffect, useRef, useState } from 'react';
46
+ import type { ScrollableNode } from 'react-x11';
47
+
48
+ import type { RowHeights, RowKey } from './heights.js';
49
+ import { cancelLater, later } from './timers.js';
50
+ import type { DelayTick } from './timers.js';
51
+
52
+ /** All this needs of a row — `TreeRow` and `TableRow` are both this. */
53
+ interface Keyed {
54
+ id: RowKey;
55
+ }
56
+
57
+ const EMPTY_KEYS: ReadonlySet<RowKey> = new Set();
58
+
59
+ /** Rows kept either side of the viewport, so a fast scroll does not show a
60
+ * gap before the next frame catches up. The components' default. */
61
+ export const DEFAULT_OVERSCAN = 6;
62
+
63
+ /** How long the viewport must have been showing unresolved content before
64
+ * the fast-scroll pill appears. A catch-up the next few frames absorb is
65
+ * not worth announcing — the pill is for the delay you can feel. */
66
+ export const SCROLL_HINT_DELAY_MS = 250;
67
+
68
+ /** How far past the overscan the idle band grows, rows per side — the
69
+ * components' default for their `prefetch` prop. Roughly two viewports of
70
+ * ordinary rows: enough that a flick lands inside it, small enough that
71
+ * holding it is never felt. */
72
+ export const DEFAULT_PREFETCH = 40;
73
+
74
+ /**
75
+ * What to build before the viewport has been measured. `onViewport` cannot
76
+ * arrive until layout has run, which is a frame after the first commit, so
77
+ * there is always one render that has to guess — and guessing "all of them"
78
+ * puts a hundred thousand rows in the tree for a frame.
79
+ */
80
+ const ASSUMED_ROWS = 40;
81
+
82
+ /** How long the pane must sit quiet before prefetch may grow. Longer than a
83
+ * frame, shorter than a reader's pause. */
84
+ const IDLE_MS = 120;
85
+ /** Between growth steps — each step is one commit of `GROW_CHUNK` rows per
86
+ * side, so the band arrives in pieces no single frame feels. */
87
+ const GROW_MS = 40;
88
+ const GROW_CHUNK = 16;
89
+ /** How far ahead the velocity lead looks — about three frames: one for the
90
+ * render to land, one for its paint, one of margin. */
91
+ const LEAD_MS = 50;
92
+ /** Velocity is an EMA of the per-event slope; a gap longer than this between
93
+ * events is a stop, not a very slow scroll. */
94
+ const VEL_GAP_MS = 250;
95
+ /** Above this the scroll is a flick, not a read — px/ms, about three rows a
96
+ * frame. What `fast()` answers with, for work worth deferring to the
97
+ * settle. */
98
+ const FAST_V = 1.5;
99
+
100
+ /** More rows than this entering the window in one render is a flood — a
101
+ * teleport or a hard flick — and floods build skeletons first. An ordinary
102
+ * notch brings in a handful and never trips this. */
103
+ export const SKELETON_THRESHOLD = 16;
104
+ /** New full rows built per render while a flood is being caught up with.
105
+ * Measured (rowcost probe, 300-row commits, warm caches): a default-shape
106
+ * row costs ~0.11ms against a skeleton's ~0.06ms — about 2×, not the 10×
107
+ * the original pacing assumed — so the budgets lean generous and the
108
+ * skeleton tier is there for heavy `render` seams and for the look of
109
+ * rows arriving, not because full rows are ruinous. */
110
+ export const BURST_BUDGET = 24;
111
+ /** The same, once the scroll has stopped: bigger steps, still paced, so a
112
+ * settle after a long flood is a few quick commits rather than one big
113
+ * one. */
114
+ export const SETTLE_BUDGET = 48;
115
+
116
+ export interface VirtualViewport {
117
+ /** The pane's vertical offset. */
118
+ top: number;
119
+ height: number;
120
+ width: number;
121
+ }
122
+
123
+ /** The rows worth building this render, and the two spacer heights that keep
124
+ * the scrollbar measuring the whole list. `last` is exclusive. */
125
+ export interface WindowSlice {
126
+ first: number;
127
+ last: number;
128
+ above: number;
129
+ below: number;
130
+ }
131
+
132
+ /** What the hook reads of its component, re-supplied every render so none of
133
+ * it goes stale inside the stable callbacks. */
134
+ export interface VirtualWindowInputs {
135
+ /** The scroll pane. `<Table>`'s body; `<Tree>`'s own root. */
136
+ box: { readonly current: ScrollableNode | null };
137
+ /** The height index, already `sync`ed to the rows this render draws. */
138
+ heights: RowHeights;
139
+ /** The rows in display order — the same list the component draws from. */
140
+ rows: readonly Keyed[];
141
+ /**
142
+ * Every height in the index is exact — the table's declared-uniform model.
143
+ * What it buys here: the idle band may grow *upward* freely. A measured
144
+ * component may only grow upward over rows the index has real numbers
145
+ * for: an unmeasured row laid out above the viewport lands at a height
146
+ * the spacer did not anticipate, and everything on screen jumps by the
147
+ * difference until the measure tick pays it back — a wobble that is
148
+ * masked while scrolling and glaring while idle. Unmeasured territory
149
+ * above is left to the overscan, which meets it during a scroll.
150
+ */
151
+ exact: boolean;
152
+ virtualizing: boolean;
153
+ overscan: number;
154
+ /** The idle band's target, rows beyond the overscan each side. `0` turns
155
+ * prefetch and the retained band off — the slice is exactly
156
+ * viewport-plus-overscan again. */
157
+ prefetch: number;
158
+ /** Rows entering the window in one render that count as a flood — below
159
+ * it everything builds in full. The component resolves it from its
160
+ * `catchup` prop; `SKELETON_THRESHOLD` is the default. */
161
+ threshold: number;
162
+ /** Full rows built per render while a flood is in flight / once it has
163
+ * settled. `BURST_BUDGET` / `SETTLE_BUDGET` are the defaults. */
164
+ burstBudget: number;
165
+ settleBudget: number;
166
+ }
167
+
168
+ export interface VirtualWindow {
169
+ view: VirtualViewport;
170
+ /** The viewport as of the last event, ahead of the state between an
171
+ * `onViewport` and the re-render it causes — what a measure tick reads. */
172
+ viewRef: { readonly current: VirtualViewport };
173
+ slice: WindowSlice;
174
+ /**
175
+ * Rows in the slice to draw as skeletons this render — the box at its
176
+ * indexed height, no content, `aria-hidden`, and **not registered as a
177
+ * mounted row**: a skeleton must not be measured into the height index or
178
+ * satisfy a reveal. Empty except while a flood is being caught up with.
179
+ */
180
+ skeletons: ReadonlySet<RowKey>;
181
+ /**
182
+ * The window teleported this render: nothing it had built overlaps where
183
+ * the viewport is now. One jump is a scrollbar page; a *run* of them
184
+ * while a burst is in flight is a thumb scrub, where every commit chases
185
+ * a viewport that has already left — the case a scroll-position overlay
186
+ * exists for, because nothing else useful can be on screen.
187
+ */
188
+ jumped: boolean;
189
+ /**
190
+ * When the viewport first stopped being whole — placeholders on screen,
191
+ * or a scrub outrunning the built rows — epoch milliseconds, `null` while
192
+ * everything in view is real. What a hint's show-delay is measured from,
193
+ * and cleared the moment the catch-up ends.
194
+ */
195
+ catchupSince: number | null;
196
+ /**
197
+ * How many of the rows **on screen** are skeletons this render — the
198
+ * measure of "the user is looking at rows that have no content yet".
199
+ * `0` the moment the viewport is fully real again, even while the band
200
+ * beyond it is still catching up: it is the signal a scroll-position
201
+ * overlay shows on, and an overlay that lingered past the last visible
202
+ * skeleton would be announcing a delay nobody can see.
203
+ */
204
+ pending: number;
205
+ /** An `onScroll` arrived. */
206
+ scrolled(top: number): void;
207
+ /** An `onViewport` arrived. The ref is updated at event time — the measure
208
+ * tick runs before the re-render this causes, and must see this size. */
209
+ sized(width: number, height: number): void;
210
+ /**
211
+ * Re-read the offset the pane is *actually* at.
212
+ *
213
+ * The pane moves silently — it resolves a queued reveal during layout, and
214
+ * re-clamps an offset the content outgrew or outshrank — and a slice built
215
+ * from the offset before those is drawn where the viewport is not: a blank
216
+ * band where the rows should be, and no way back until a scroll of your
217
+ * own re-syncs it by accident.
218
+ */
219
+ sync(): void;
220
+ /** Whether a scroll is in flight — an event arrived and the idle clock has
221
+ * not run out since. What the growth pauses on, and what a component may
222
+ * defer non-urgent per-render work on. */
223
+ scrolling(): boolean;
224
+ /**
225
+ * Scrolling, and too fast to be reading: work whose payoff is precision —
226
+ * measuring rows, adapting estimates — is churn at this speed, every
227
+ * correction invalidated by the next event. The settle tick that follows
228
+ * any burst is where deferred work catches up.
229
+ */
230
+ fast(): boolean;
231
+ }
232
+
233
+ /**
234
+ * The window a virtualizing component builds its rows from.
235
+ *
236
+ * Owns the viewport state and the slice; the component keeps everything the
237
+ * two virtualizers genuinely differ on — what a row *is*, measuring it,
238
+ * revealing it. Call after `heights.sync`, so the slice is computed against
239
+ * the rows this render is about to draw.
240
+ */
241
+ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
242
+ const [view, setView] = useState<VirtualViewport>({
243
+ top: 0,
244
+ height: 0,
245
+ width: 0,
246
+ });
247
+ const viewRef = useRef(view);
248
+ viewRef.current = view;
249
+
250
+ const inp = useRef(inputs);
251
+ inp.current = inputs;
252
+
253
+ /** Re-render with the same viewport — how an idle tick asks the slice to
254
+ * be recomputed so the band can take its next growth step. */
255
+ const [, bump] = useState(0);
256
+ /** The window built last render, kept so this render can keep it. */
257
+ const built = useRef<{ first: number; last: number } | null>(null);
258
+ /** The scroll's slope, px/ms, EMA over the events of the current burst —
259
+ * signed, positive downwards. Meaningful only while `active`. */
260
+ const vel = useRef({ t: 0, top: 0, v: 0 });
261
+ /** A burst is in flight: velocity is live and growth is paused. Cleared by
262
+ * the idle tick rather than by a clock read at render time, so a render's
263
+ * output never depends on when it ran. */
264
+ const active = useRef(false);
265
+ /** Whether the slice this render produced still wants another step — band
266
+ * growth left to do, or skeletons left to upgrade — read by the idle tick
267
+ * to decide whether a re-render is worth it. */
268
+ const wantsGrowth = useRef(false);
269
+ /** Skeletons the slice this render carries — the tick shortens its own
270
+ * clock while any remain, so a flood is caught up with at `GROW_MS` pace
271
+ * rather than waiting out the idle delay. */
272
+ const hasSkeletons = useRef(false);
273
+ /** The rows currently built in full, by id — everything else in the slice
274
+ * is a skeleton. Ids, not indexes, so a re-sort moves the rows without
275
+ * demoting them. */
276
+ const real = useRef<Set<RowKey>>(new Set());
277
+ /** When the current catch-up began — see `catchupSince` on the result. */
278
+ const catchupStart = useRef<number | null>(null);
279
+ const idleTimer = useRef<DelayTick>(null);
280
+
281
+ const tick = useCallback((): void => {
282
+ idleTimer.current = null;
283
+ const wasActive = active.current;
284
+ active.current = false;
285
+ if (!wasActive && !wantsGrowth.current) return;
286
+ // One re-render on settling even when there is nothing to grow: it is
287
+ // the render a component's deferred-while-scrolling work (measuring,
288
+ // estimate adaptation) runs on.
289
+ bump((n) => n + 1);
290
+ if (wantsGrowth.current) {
291
+ idleTimer.current = later(tick, GROW_MS);
292
+ }
293
+ }, []);
294
+
295
+ const scrolled = useCallback(
296
+ (top: number): void => {
297
+ // Only a virtualizing component reads the vertical offset — a whole
298
+ // one has no slice to rebuild, and re-rendering it on a scroll it
299
+ // already drew would be work for nothing.
300
+ if (!inp.current.virtualizing) return;
301
+ (globalThis as any).__scrolls = ((globalThis as any).__scrolls ?? 0) + 1;
302
+ const now = Date.now();
303
+ const s = vel.current;
304
+ const dt = now - s.t;
305
+ if (dt > 0 && dt < VEL_GAP_MS) {
306
+ // Blend rather than replace: a wheel's notches arrive unevenly, and
307
+ // the lead must not whip around on every one.
308
+ s.v = s.v * 0.6 + ((top - s.top) / dt) * 0.4;
309
+ } else {
310
+ s.v = 0;
311
+ }
312
+ s.t = now;
313
+ s.top = top;
314
+ active.current = true;
315
+ cancelLater(idleTimer.current);
316
+ idleTimer.current = later(tick, IDLE_MS);
317
+ setView((prev) => (prev.top === top ? prev : { ...prev, top }));
318
+ },
319
+ [tick],
320
+ );
321
+
322
+ const sized = useCallback((width: number, height: number): void => {
323
+ viewRef.current = { ...viewRef.current, width, height };
324
+ setView((prev) =>
325
+ prev.width === width && prev.height === height
326
+ ? prev
327
+ : { ...prev, width, height },
328
+ );
329
+ }, []);
330
+
331
+ const sync = useCallback((): void => {
332
+ const { box, virtualizing } = inp.current;
333
+ const node = box.current;
334
+ if (!node || !virtualizing) return;
335
+ const y = node.scrollY;
336
+ setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
337
+ }, []);
338
+
339
+ const scrolling = useCallback((): boolean => active.current, []);
340
+
341
+ const fast = useCallback(
342
+ (): boolean => active.current && Math.abs(vel.current.v) >= FAST_V,
343
+ [],
344
+ );
345
+
346
+ // The idle clock has to start somewhere even when nothing ever scrolls —
347
+ // a table that mounts and sits still owes itself the band. After any
348
+ // render that still wants growth, make sure a tick is coming — and while
349
+ // skeletons remain, a *near* one: they are on screen, and waiting out the
350
+ // idle delay to fill them in would be a visible pause.
351
+ useEffect(() => {
352
+ if (hasSkeletons.current) {
353
+ cancelLater(idleTimer.current);
354
+ idleTimer.current = later(tick, GROW_MS);
355
+ } else if (wantsGrowth.current && idleTimer.current === null) {
356
+ idleTimer.current = later(tick, IDLE_MS);
357
+ }
358
+ });
359
+ useEffect(() => () => cancelLater(idleTimer.current), []);
360
+
361
+ const {
362
+ heights,
363
+ rows,
364
+ exact,
365
+ virtualizing,
366
+ overscan,
367
+ prefetch,
368
+ threshold,
369
+ burstBudget,
370
+ settleBudget,
371
+ } = inputs;
372
+ const count = rows.length;
373
+
374
+ let first = 0;
375
+ let last = count;
376
+ let jumped = false;
377
+ wantsGrowth.current = false;
378
+ /** Skeletons are still being filled in from the last render — the band
379
+ * must not grow while they are, or the debt outruns the catch-up. */
380
+ const catchingUp = hasSkeletons.current;
381
+ if (virtualizing) {
382
+ // The core: what is on screen plus the overscan — extended, while a
383
+ // burst is in flight, by where that burst will be in a few frames. The
384
+ // lead goes on the edge being exposed; the overscan already covers the
385
+ // trailing one.
386
+ //
387
+ // **Clamped to one viewport.** A scrollbar scrub moves millions of
388
+ // pixels a second, and an unclamped `v × LEAD_MS` asked the slice for
389
+ // tens of thousands of rows — one commit mounting them froze the app
390
+ // for seconds, the very thing a virtualized list exists to prevent. A
391
+ // viewport ahead is all a lead can usefully buy: anything further is
392
+ // out of sight again before it finishes landing.
393
+ const rawLead = active.current ? vel.current.v * LEAD_MS : 0;
394
+ const lead = Math.max(-view.height, Math.min(view.height, rawLead));
395
+ const topEdge = Math.max(0, view.top + Math.min(0, lead));
396
+ const coreFirst = Math.max(0, heights.indexAt(topEdge) - overscan);
397
+ let coreLast: number;
398
+ if (view.height > 0) {
399
+ const botEdge = view.top + view.height + Math.max(0, lead);
400
+ coreLast = Math.min(count, heights.indexAt(botEdge) + 1 + overscan);
401
+ } else {
402
+ coreLast = Math.min(count, coreFirst + ASSUMED_ROWS);
403
+ }
404
+ first = coreFirst;
405
+ last = coreLast;
406
+
407
+ if (prefetch > 0 && view.height > 0) {
408
+ // Keep what the last render built, so far as it touches the window —
409
+ // a band the viewport just left is the band a reversal comes back to.
410
+ // A band that does not touch it at all is a teleport's leavings, and
411
+ // rebuilding from the core is cheaper than carrying rows a screenful
412
+ // of nothing away.
413
+ const b = built.current;
414
+ if (b) {
415
+ const bf = Math.min(Math.max(0, b.first), count);
416
+ const bl = Math.min(Math.max(bf, b.last), count);
417
+ if (bl > bf && bl >= first && bf <= last) {
418
+ first = Math.min(first, bf);
419
+ last = Math.max(last, bl);
420
+ } else if (bl > bf) {
421
+ jumped = true;
422
+ }
423
+ }
424
+
425
+ // Grown while idle, one chunk per side per tick — never during a
426
+ // burst, whose renders have rows of their own to build, and never
427
+ // while skeletons are still filling in, whose catch-up comes first.
428
+ // Upward growth stops at the first row the index has no real height
429
+ // for — see `exact` on the inputs for why building one would make
430
+ // the view wobble while idle.
431
+ const targetFirst = Math.max(0, coreFirst - prefetch);
432
+ const targetLast = Math.min(count, coreLast + prefetch);
433
+ const growableUp = (): boolean =>
434
+ first > targetFirst && (exact || heights.isMeasured(first - 1));
435
+ if (!active.current && !catchingUp) {
436
+ const stop = Math.max(targetFirst, first - GROW_CHUNK);
437
+ while (first > stop && growableUp()) first--;
438
+ if (last < targetLast) last = Math.min(targetLast, last + GROW_CHUNK);
439
+ }
440
+ wantsGrowth.current = growableUp() || last < targetLast;
441
+
442
+ // The budget: the core plus a full band each side. Trim the trailing
443
+ // side first — those rows are the furthest from coming back. The
444
+ // top-side cut stops at the first row the index has no real height
445
+ // for, the same rule growth follows and for the reverse reason: a
446
+ // row laid out taller than the index believes contributes its real
447
+ // height while mounted and its guessed one once dropped, so cutting
448
+ // it silently shrinks the content above the viewport and the view
449
+ // yanks up by the difference with no debt left to put it right. Held
450
+ // a render or two longer, it gets measured, and the next trim takes
451
+ // it cleanly.
452
+ const budget = coreLast - coreFirst + 2 * prefetch;
453
+ let excess = last - first - budget;
454
+ if (excess > 0) {
455
+ const cutAbove = (want: number): number => {
456
+ let k = 0;
457
+ while (k < want && (exact || heights.isMeasured(first + k))) k++;
458
+ return k;
459
+ };
460
+ const aboveExtra = coreFirst - first;
461
+ const belowExtra = last - coreLast;
462
+ if (vel.current.v >= 0) {
463
+ const cut = cutAbove(Math.min(aboveExtra, excess));
464
+ first += cut;
465
+ excess -= cut;
466
+ last -= Math.min(belowExtra, excess);
467
+ } else {
468
+ const cut = Math.min(belowExtra, excess);
469
+ last -= cut;
470
+ excess -= cut;
471
+ first += cutAbove(Math.min(aboveExtra, excess));
472
+ }
473
+ }
474
+ }
475
+ built.current = { first, last };
476
+ } else {
477
+ built.current = null;
478
+ }
479
+
480
+ // The skeleton tier: which of the slice's rows are worth building in full
481
+ // *this* render. All of them, almost always — a flood is the exception.
482
+ let skeletons: ReadonlySet<RowKey> = EMPTY_KEYS;
483
+ let pending = 0;
484
+ hasSkeletons.current = false;
485
+ if (virtualizing) {
486
+ const prev = real.current;
487
+ const next = new Set<RowKey>();
488
+ let entering = 0;
489
+ for (let i = first; i < last; i++) {
490
+ if (prev.has(rows[i].id)) next.add(rows[i].id);
491
+ else entering++;
492
+ }
493
+ // A window with nothing carried over and no scroll in flight is a mount
494
+ // or a wholesale data change, not a flood — those build in full, or the
495
+ // first paint would be skeletons.
496
+ const flood = entering > threshold && (active.current || next.size > 0);
497
+ if (!flood) {
498
+ for (let i = first; i < last; i++) next.add(rows[i].id);
499
+ } else {
500
+ // Viewport rows first — they are the ones being looked at — then
501
+ // outward, up to the budget; the rest are skeletons until the ticks
502
+ // catch up.
503
+ let budget = active.current ? burstBudget : settleBudget;
504
+ const vFirst = heights.indexAt(view.top);
505
+ const vLast =
506
+ view.height > 0 ? heights.indexAt(view.top + view.height) : vFirst;
507
+ const take = (i: number): void => {
508
+ if (i < first || i >= last || budget <= 0) return;
509
+ if (!next.has(rows[i].id)) {
510
+ next.add(rows[i].id);
511
+ budget--;
512
+ }
513
+ };
514
+ for (let i = vFirst; i <= vLast && i < last; i++) take(i);
515
+ for (
516
+ let d = 1;
517
+ budget > 0 && (vFirst - d >= first || vLast + d < last);
518
+ d++
519
+ ) {
520
+ take(vLast + d);
521
+ take(vFirst - d);
522
+ }
523
+ const skel = new Set<RowKey>();
524
+ for (let i = first; i < last; i++) {
525
+ if (!next.has(rows[i].id)) {
526
+ skel.add(rows[i].id);
527
+ if (i >= vFirst && i <= vLast) pending++;
528
+ }
529
+ }
530
+ skeletons = skel;
531
+ hasSkeletons.current = skel.size > 0;
532
+ if (skel.size > 0) wantsGrowth.current = true;
533
+ }
534
+ real.current = next;
535
+ } else if (real.current.size > 0) {
536
+ real.current = new Set();
537
+ }
538
+
539
+ // The catch-up clock: started the first render the viewport stops being
540
+ // whole — placeholders in view, or a scrub outrunning the built rows —
541
+ // and cleared when the catch-up ends, on the same condition a hint's
542
+ // latch releases. Renders keep coming while it runs (the skeleton ticks,
543
+ // the scrub's own events), so a deadline measured against it is
544
+ // re-evaluated within a tick of passing.
545
+ if (virtualizing && (pending > 0 || (jumped && active.current))) {
546
+ catchupStart.current ??= Date.now();
547
+ } else if (pending === 0 && !active.current) {
548
+ catchupStart.current = null;
549
+ }
550
+
551
+ /** Where the slice starts, and how much of the list is below it — the two
552
+ * spacers that keep the scrollbar measuring the whole list. */
553
+ const above = virtualizing ? heights.offsetAt(first) : 0;
554
+ const below = virtualizing ? heights.total() - heights.offsetAt(last) : 0;
555
+
556
+ return {
557
+ view,
558
+ viewRef,
559
+ slice: { first, last, above, below },
560
+ skeletons,
561
+ jumped,
562
+ catchupSince: catchupStart.current,
563
+ pending,
564
+ scrolled,
565
+ sized,
566
+ sync,
567
+ scrolling,
568
+ fast,
569
+ };
570
+ }