@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.
- package/dist/internal/heights.d.ts +26 -1
- package/dist/internal/heights.d.ts.map +1 -1
- package/dist/internal/heights.js +57 -6
- package/dist/internal/heights.js.map +1 -1
- package/dist/internal/scroll.d.ts +11 -0
- package/dist/internal/scroll.d.ts.map +1 -1
- package/dist/internal/scroll.js +43 -3
- package/dist/internal/scroll.js.map +1 -1
- package/dist/internal/timers.d.ts +8 -0
- package/dist/internal/timers.d.ts.map +1 -1
- package/dist/internal/timers.js +10 -0
- package/dist/internal/timers.js.map +1 -1
- package/dist/internal/window.d.ts +161 -0
- package/dist/internal/window.d.ts.map +1 -0
- package/dist/internal/window.js +417 -0
- package/dist/internal/window.js.map +1 -0
- package/dist/table/index.d.ts +73 -2
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +405 -161
- package/dist/table/index.js.map +1 -1
- package/dist/tree/index.d.ts +65 -5
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +446 -189
- package/dist/tree/index.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/heights.ts +57 -6
- package/src/internal/scroll.ts +59 -3
- package/src/internal/timers.ts +13 -0
- package/src/internal/window.ts +570 -0
- package/src/table/index.ts +599 -178
- package/src/tree/index.ts +687 -260
|
@@ -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
|
+
}
|