@react-x11/components 0.2.0 → 0.2.1

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,288 @@
1
+ // Scrolling a row into view, when neither the row nor the scroll pane is
2
+ // ready to be asked yet.
3
+ //
4
+ // **Shared between `<Tree>` and `<Table>`** — the third piece both
5
+ // virtualizers stand on, beside `./heights.ts` and `./timers.ts`, and here
6
+ // for the same reason: the logic is subtle, it is identical in both, and a
7
+ // second copy of it is a second place for the bug below to come back.
8
+ //
9
+ // Two things a scroll pane does silently, both of them in the gap between a
10
+ // commit and the layout it causes, and both of them enough on their own to
11
+ // leave a virtualized list drawing rows the viewport is no longer looking at:
12
+ //
13
+ // 1. **`scrollTo` clamps against the content height the *last* layout
14
+ // measured.** A row appended in the commit now being laid out is past
15
+ // the bottom that clamp knows about, so the request lands short — and
16
+ // short *for good*, because nothing about it is retried. That is the
17
+ // live tail: a list that scrolls to its newest row on every update and
18
+ // settles one update behind, permanently. On mount, where no content
19
+ // has been laid out at all, the scroll simply never happens.
20
+ //
21
+ // 2. **The pane moves without saying so.** It resolves a queued
22
+ // `scrollIntoView` during layout, and it re-clamps an offset the
23
+ // content has outgrown or outshrunk — neither of which fires
24
+ // `onScroll`. A component that learns the offset only from that event
25
+ // keeps building its slice from where the pane *was*: the rows are
26
+ // drawn off-viewport, there is a blank band where they should be, and
27
+ // nothing puts it right until a scroll of your own re-syncs it by
28
+ // accident.
29
+ //
30
+ // So a reveal here is not a one-shot: it is a **debt**, kept by row id,
31
+ // attempted at once and re-tried on the layout that makes the rest of the
32
+ // move possible. It is dropped as soon as the row is in view, leaves the
33
+ // list, or the user scrolls somewhere themselves — an owed scroll must never
34
+ // yank the list back out from under a hand on the wheel.
35
+ //
36
+ // The offset is read back from the pane rather than trusted to the event;
37
+ // `useReveal` does not do that itself, because what a component *does* with
38
+ // the offset differs (a slice to rebuild, a header to shift), but it is the
39
+ // other half of the same fix and both components run it on the same tick.
40
+
41
+ import { useCallback, useEffect, useRef } from 'react';
42
+ import type { DrawnNode, ScrollableNode } from 'react-x11';
43
+
44
+ import type { RowHeights, RowKey } from './heights.js';
45
+ import { afterLayout, cancelAfterLayout } from './timers.js';
46
+ import type { LayoutTick } from './timers.js';
47
+
48
+ /** All a reveal needs of a row: `<TreeRow>` and `<TableRow>` are both this. */
49
+ interface Keyed {
50
+ id: RowKey;
51
+ }
52
+
53
+ /** A row on screen, and the index it was drawn at — the shape both
54
+ * components already keep their mounted rows in. */
55
+ interface Drawn {
56
+ node: DrawnNode;
57
+ at: number;
58
+ }
59
+
60
+ /** What the component lends the reveal: everything it already has, by ref,
61
+ * so nothing here goes stale between a render and the tick after layout. */
62
+ export interface RevealSources {
63
+ /** The scroll pane. `<Table>`'s body; `<Tree>`'s own root. */
64
+ box: { readonly current: ScrollableNode | null };
65
+ /** The rows in display order. */
66
+ rows: { readonly current: readonly Keyed[] };
67
+ /** The rows on screen, by id. */
68
+ nodes: { readonly current: ReadonlyMap<RowKey, Drawn> };
69
+ /** Where the rows that are *not* on screen are — and how tall a row that
70
+ * has never been drawn is assumed to be. */
71
+ heights: RowHeights;
72
+ }
73
+
74
+ export interface Reveal {
75
+ /** Owe a scroll to this row, and try to pay it now. */
76
+ to(id: RowKey): void;
77
+ /**
78
+ * Try again: after a layout, or when the content changed size.
79
+ *
80
+ * Pass `provisional` on a pass where the height index just moved. The rows
81
+ * are still laid out at the heights it no longer believes, so a row that
82
+ * looks in view is not proof of anything yet — a row measured taller than
83
+ * the guess pushes everything after it down, and the row that was reached a
84
+ * moment ago ends up under the fold. The debt is kept until a pass that
85
+ * measured nothing new confirms it, which is exactly when the heights have
86
+ * converged.
87
+ */
88
+ retry(provisional?: boolean): void;
89
+ /** Scroll the pane, recording that this component is the one that asked. */
90
+ scrollTo(y: number): void;
91
+ /** An `onScroll` arrived. One this component did not ask for is the user
92
+ * taking over, and it cancels whatever was owed. */
93
+ heard(scrollY: number): void;
94
+ }
95
+
96
+ /**
97
+ * The scroll a component owes a row, and the bookkeeping that pays it.
98
+ *
99
+ * Stable across renders — every method reads through the refs it was handed,
100
+ * so an event handler or a layout tick built in an earlier render still sees
101
+ * the current rows.
102
+ */
103
+ export function useReveal(sources: RevealSources): Reveal {
104
+ const src = useRef(sources);
105
+ src.current = sources;
106
+ /** The row still owed a scroll, by id. */
107
+ const owed = useRef<RowKey | null>(null);
108
+ /**
109
+ * The row the last reveal settled on, kept for one reason: a measurement
110
+ * pass can move the heights that settlement was judged against. A row that
111
+ * was in view when the debt was paid is not in view any more once the rows
112
+ * around it turn out taller than the index believed, and the honest answer
113
+ * to "is that still true?" is to owe it again and look. Measurement
114
+ * converges, so this stops asking.
115
+ */
116
+ const settled = useRef<RowKey | null>(null);
117
+ /** The offset the last scroll *this component* asked for, so the
118
+ * `onScroll` that answers it is not read as the user taking over. */
119
+ const asked = useRef<number | null>(null);
120
+ /**
121
+ * What the pane looked like at the last attempt that could not move.
122
+ *
123
+ * An attempt clamped by a content height that is a layout out of date has
124
+ * to be looked at again — and nothing else will schedule that look: the
125
+ * scroll that did not happen renders nothing, so the component's own tick
126
+ * after layout never comes. One is queued here instead, and only while the
127
+ * pane keeps changing under it: an attempt that finds the same offset and
128
+ * the same content as the last one has nothing new to try, and stops.
129
+ */
130
+ const stuck = useRef<{ y: number; content: number } | null>(null);
131
+ const look = useRef<LayoutTick>(null);
132
+ useEffect(() => () => cancelAfterLayout(look.current), []);
133
+
134
+ const scrollTo = useCallback((y: number): void => {
135
+ const box = src.current.box.current;
136
+ if (!box) return;
137
+ // Clamped here rather than left to the container, so what comes back on
138
+ // `onScroll` is the number that was asked for and can be recognised.
139
+ const to = Math.min(
140
+ Math.max(0, y),
141
+ Math.max(0, box.contentHeight - box.abs.height),
142
+ );
143
+ if (to === box.scrollY) return;
144
+ asked.current = to;
145
+ box.scrollTo({ y: to });
146
+ }, []);
147
+
148
+ const retry = useCallback(
149
+ (provisional?: boolean): void => {
150
+ const { box: boxRef, rows: rowsRef, nodes, heights } = src.current;
151
+ const box = boxRef.current;
152
+ // The heights just moved, so what the last reveal settled on was settled
153
+ // against numbers that have changed: owe it again until it can be
154
+ // confirmed at the new ones.
155
+ if (provisional && owed.current === null) owed.current = settled.current;
156
+ const id = owed.current;
157
+ if (!box || id === null) return;
158
+ const rows = rowsRef.current;
159
+ const at = rows.findIndex((r) => r.id === id);
160
+ if (at < 0) {
161
+ // the row left the list
162
+ owed.current = null;
163
+ settled.current = null;
164
+ return;
165
+ }
166
+ const viewport = box.abs.height;
167
+ if (viewport <= 0) return; // nothing laid out to scroll inside yet
168
+
169
+ // How far the row is outside the pane, in pixels of scrolling. The row's
170
+ // own rect answers when it is mounted — it knows what it really laid out
171
+ // at, and the arithmetic stays in screen coordinates, so a pane with
172
+ // padding on it needs no correction. The height index answers when the
173
+ // row is not mounted, which while virtualizing is the normal case.
174
+ const drawn = nodes.current.get(id);
175
+ const mounted =
176
+ drawn && drawn.node.abs.height > 0 && rows[drawn.at]?.id === id
177
+ ? drawn.node
178
+ : null;
179
+ const placed = mounted
180
+ ? {
181
+ above: box.abs.y - mounted.abs.y,
182
+ below: mounted.abs.y + mounted.abs.height - (box.abs.y + viewport),
183
+ height: mounted.abs.height,
184
+ }
185
+ : {
186
+ above: box.scrollY - heights.offsetAt(at),
187
+ below:
188
+ heights.offsetAt(at) +
189
+ heights.heightAt(at) -
190
+ (box.scrollY + viewport),
191
+ height: heights.heightAt(at),
192
+ };
193
+ /**
194
+ * Whether this is worth *settling* on, or only worth acting on.
195
+ *
196
+ * Two ways a row can look in view and not stay there. Its own placement
197
+ * may be a guess — the index's answer for a row nothing has measured —
198
+ * and "in view" judged from a guess is how a tail lands short of a bottom
199
+ * that has not been measured yet. Or a row *between* the viewport and it
200
+ * may still be a guess, and every one of those that turns out taller than
201
+ * the index believed pushes this row down by the difference, out of the
202
+ * view it had just been brought into.
203
+ *
204
+ * Both are settled by the same thing: measurement converges, so the rows
205
+ * that matter stop being guesses. A component that measures nothing at all
206
+ * has no guesses to wait on — `hasMeasurements` is how that is told
207
+ * apart, and it is what keeps a declared-uniform table from owing a debt
208
+ * for ever.
209
+ */
210
+ let certain = mounted !== null || heights.isMeasured(at);
211
+ if (certain && heights.hasMeasurements()) {
212
+ const from = Math.min(at, heights.indexAt(box.scrollY));
213
+ const to = Math.max(at, heights.indexAt(box.scrollY + viewport));
214
+ for (let i = from; i <= to && certain; i++) {
215
+ certain = heights.isMeasured(i);
216
+ }
217
+ }
218
+
219
+ let move = 0;
220
+ if (placed.height >= viewport) {
221
+ // A row taller than the pane is never *fully* in view, and asking for
222
+ // both its edges in turn is a debt that alternates for ever. Its top is
223
+ // the part worth showing — a wrapped row reads from its first line —
224
+ // and arriving there settles it.
225
+ move = -placed.above;
226
+ } else if (placed.above > 0) move = -placed.above;
227
+ else if (placed.below > 0) move = placed.below;
228
+
229
+ if (move === 0) {
230
+ // As far in view as it can be — settled, unless what that was judged
231
+ // against is still moving: a placement that came from a guess, or a
232
+ // pass that has just changed the heights under it.
233
+ if (certain && !provisional) {
234
+ settled.current = id;
235
+ owed.current = null;
236
+ }
237
+ return;
238
+ }
239
+ // A move the pane cannot make yet leaves the debt standing: the layout
240
+ // that admits the rows just appended is the one that will let it finish.
241
+ const was = box.scrollY;
242
+ scrollTo(was + move);
243
+ if (box.scrollY !== was) {
244
+ stuck.current = null; // it moved; the scroll it caused brings us back
245
+ return;
246
+ }
247
+ const seen = stuck.current;
248
+ const now = { y: box.scrollY, content: box.contentHeight };
249
+ stuck.current = now;
250
+ if (seen && seen.y === now.y && seen.content === now.content) return;
251
+ cancelAfterLayout(look.current);
252
+ look.current = afterLayout(() => {
253
+ look.current = null;
254
+ retryRef.current?.();
255
+ });
256
+ },
257
+ [scrollTo],
258
+ );
259
+
260
+ /** `retry` referring to itself through a ref, so the queued look calls the
261
+ * current one rather than closing over the render that queued it. */
262
+ const retryRef = useRef<(() => void) | null>(null);
263
+ retryRef.current = retry;
264
+
265
+ const to = useCallback(
266
+ (id: RowKey): void => {
267
+ // Recorded before the attempt rather than after it, because the
268
+ // interesting case is the one that cannot succeed yet.
269
+ owed.current = id;
270
+ settled.current = null;
271
+ stuck.current = null;
272
+ retry();
273
+ },
274
+ [retry],
275
+ );
276
+
277
+ const heard = useCallback((scrollY: number): void => {
278
+ // A scroll this component did not ask for ends the whole chase, not just
279
+ // the outstanding half of it.
280
+ if (scrollY !== asked.current) {
281
+ owed.current = null;
282
+ settled.current = null;
283
+ }
284
+ asked.current = null;
285
+ }, []);
286
+
287
+ return { to, retry, scrollTo, heard };
288
+ }
@@ -64,6 +64,7 @@ import type { Host } from './hx.js';
64
64
  // header of src/internal/heights.ts says why.
65
65
  import { RowHeights } from '../internal/heights.js';
66
66
  import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
67
+ import { useReveal } from '../internal/scroll.js';
67
68
  import {
68
69
  MIN_COLUMN,
69
70
  columnValue,
@@ -577,6 +578,40 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
577
578
  [columns, userWidths, view.width],
578
579
  );
579
580
 
581
+ /**
582
+ * The scroll the table owes a row, and the pane's real offset read back
583
+ * after every layout — the two halves of `../internal/scroll.ts`, which
584
+ * says why a reveal cannot be a one-shot and why `onScroll` is not the
585
+ * whole story.
586
+ */
587
+ const reveal = useReveal({
588
+ box: body,
589
+ rows: orderedRef,
590
+ nodes: rowNodes,
591
+ heights,
592
+ });
593
+
594
+ /**
595
+ * Re-read the offset the body is *actually* at.
596
+ *
597
+ * The pane moves silently — it resolves a queued reveal during layout, and
598
+ * re-clamps an offset the content outgrew or outshrank — and a slice built
599
+ * from the offset before those is drawn where the viewport is not: a blank
600
+ * band where the rows should be, and no way back until a scroll of your own
601
+ * re-syncs it by accident.
602
+ */
603
+ const syncScroll = useCallback((): void => {
604
+ const box = body.current;
605
+ if (!box) return;
606
+ const { scrollX: x, scrollY: y } = box;
607
+ setScrollX((prev) => (prev === x ? prev : x));
608
+ // Only a virtualizing table reads the vertical offset — a whole one has
609
+ // no slice to rebuild, and re-rendering it on a scroll it already drew
610
+ // would be work for nothing.
611
+ if (virtualizing)
612
+ setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
613
+ }, [virtualizing]);
614
+
580
615
  /**
581
616
  * Read back what the rows on screen actually laid out at.
582
617
  *
@@ -587,12 +622,12 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
587
622
  * the viewport anchor shift the scroll offset by their delta, or
588
623
  * measuring a row already scrolled past yanks the list under the pointer.
589
624
  */
590
- const measureRows = useCallback((): void => {
591
- if (uniform || !virtualizing) return;
625
+ const measureRows = useCallback((): boolean => {
626
+ if (uniform || !virtualizing) return false;
592
627
  // Before the first `onViewport` the flex columns sit on their floors and
593
628
  // every row is laid out against a width that is about to change — there
594
629
  // is nothing honest to measure yet.
595
- if (viewRef.current.width <= 0) return;
630
+ if (viewRef.current.width <= 0) return false;
596
631
  // A row laid out at a width the columns no longer resolve to is a
597
632
  // measurement of the wrong table, and it must not be recorded — a row
598
633
  // that scrolls out before the corrected pass would keep a wrong-width
@@ -620,45 +655,49 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
620
655
  changed = true;
621
656
  if (at < anchor) shift += height - was;
622
657
  }
623
- if (!changed) return;
658
+ if (!changed) return false;
624
659
  if (shift !== 0 && box) {
625
- box.scrollTo({ y: Math.max(0, box.scrollY + shift) });
660
+ reveal.scrollTo(box.scrollY + shift);
626
661
  }
627
662
  setMeasured((n) => n + 1);
663
+ return true;
628
664
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
629
665
  // stable instance
630
666
  }, [uniform, virtualizing]);
631
667
 
668
+ /**
669
+ * The one tick after layout, and everything that can only be known there:
670
+ * what the rows measured, whether an owed scroll can go further now that
671
+ * the new rows are laid out, and where the body actually ended up. In that
672
+ * order — each step can move the offset the next one reads.
673
+ *
674
+ * Scheduled for every render a virtualized table makes, because every one
675
+ * of them can move the offset its next slice is built from. A whole table
676
+ * needs none of it: `onViewport` is when its content can have been
677
+ * re-clamped, and it rebuilds no slice anyway.
678
+ */
632
679
  useEffect(() => {
633
- if (uniform || !virtualizing) return undefined;
634
- const id = afterLayout(measureRows);
680
+ if (!virtualizing) return undefined;
681
+ const id = afterLayout(() => {
682
+ // `measureRows` first, and its answer handed on: a pass that moved the
683
+ // heights has not settled anything, and an owed scroll judged against
684
+ // the layout it is about to invalidate is not owed any less.
685
+ reveal.retry(measureRows());
686
+ syncScroll();
687
+ });
635
688
  return () => cancelAfterLayout(id);
636
689
  });
637
690
 
638
- /**
639
- * Put a row in view. A mounted row can say where it is, and
640
- * `scrollIntoView` then works whatever height it turned out to be; a row
641
- * that is not mounted has no geometry to ask, so the height index answers
642
- * instead.
643
- */
644
- const reveal = useCallback((at: number): void => {
645
- const box = body.current;
646
- const row = orderedRef.current[at];
647
- if (!box || !row) return;
648
- const drawn = rowNodes.current.get(row.id);
649
- if (drawn) {
650
- box.scrollIntoView(drawn.node);
651
- return;
652
- }
653
- const top = heights.offsetAt(at);
654
- const rowH = heights.heightAt(at);
655
- const height = viewRef.current.height;
656
- if (top < box.scrollY) box.scrollTo({ y: top });
657
- else if (height > 0 && top + rowH > box.scrollY + height) {
658
- box.scrollTo({ y: top + rowH - height });
659
- }
660
- // eslint-disable-next-line react-hooks/exhaustive-deps
661
- }, []);
691
+ /** Put a row in view, by the index its call site already has. */
692
+ const revealAt = useCallback(
693
+ (at: number): void => {
694
+ const row = orderedRef.current[at];
695
+ if (row) reveal.to(row.id);
696
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
697
+ // stable handle
698
+ },
699
+ [reveal],
700
+ );
662
701
 
663
702
  const commitSingle = useCallback(
664
703
  (row: TableRow<Row>): void => {
@@ -690,7 +729,7 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
690
729
  if (selectionMode === 'none') return;
691
730
  if (selectionMode === 'single') {
692
731
  commitSingle(row);
693
- reveal(row.index);
732
+ revealAt(row.index);
694
733
  return;
695
734
  }
696
735
  cursorRef.current = row.id;
@@ -716,9 +755,9 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
716
755
  anchorRef.current = row.id;
717
756
  commitMulti([row.id], { type: 'replace', id: row.id, row: row.row });
718
757
  }
719
- reveal(row.index);
758
+ revealAt(row.index);
720
759
  },
721
- [selectionMode, commitSingle, commitMulti, reveal],
760
+ [selectionMode, commitSingle, commitMulti, revealAt],
722
761
  );
723
762
 
724
763
  const activate = useCallback(
@@ -882,13 +921,13 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
882
921
  scrollToRow: (id) => {
883
922
  const row = orderedRef.current.find((r) => r.id === id);
884
923
  if (!row) return false;
885
- reveal(row.index);
924
+ revealAt(row.index);
886
925
  return true;
887
926
  },
888
927
  handleKey,
889
928
  rows: () => orderedRef.current,
890
929
  }),
891
- [tap, handleKey, reveal, selectionMode, selected],
930
+ [tap, handleKey, revealAt, selectionMode, selected],
892
931
  );
893
932
 
894
933
  // --- rendering -----------------------------------------------------------
@@ -1185,6 +1224,10 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1185
1224
  ref: body,
1186
1225
  style: s.body,
1187
1226
  onScroll: (ev) => {
1227
+ // A scroll this component did not ask for is the user taking over,
1228
+ // and an owed `scrollToRow` must not yank the list back out from
1229
+ // under them on the next layout.
1230
+ reveal.heard(ev.scrollY);
1188
1231
  setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
1189
1232
  if (virtualizing) {
1190
1233
  setView((prev) =>
@@ -1210,6 +1253,14 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1210
1253
  ? prev
1211
1254
  : { ...prev, height: ev.height, width: ev.width },
1212
1255
  );
1256
+ // The content just changed size, which is both the moment an owed
1257
+ // scroll can reach further than the clamp let it and the moment the
1258
+ // container may have re-clamped the offset without saying so. It is
1259
+ // not a moment anything can be *settled* in while rows are still
1260
+ // being measured: this runs from layout, a tick before the pass that
1261
+ // reads those rows back.
1262
+ reveal.retry(virtualizing && !uniform);
1263
+ syncScroll();
1213
1264
  onViewport?.(ev);
1214
1265
  },
1215
1266
  },
package/src/tree/index.ts CHANGED
@@ -59,6 +59,7 @@ import type { Host } from './hx.js';
59
59
  // header of src/internal/heights.ts says why.
60
60
  import { RowHeights } from '../internal/heights.js';
61
61
  import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
62
+ import { useReveal } from '../internal/scroll.js';
62
63
  import { typeAheadChar, useTypeAhead } from './internal.js';
63
64
  import {
64
65
  branchEdges,
@@ -571,32 +572,46 @@ export function Tree<T = TreeItem>({
571
572
  );
572
573
 
573
574
  /**
574
- * Put a row in view.
575
+ * The scroll the tree owes a row, and the pane's real offset read back
576
+ * after every layout — the two halves of `../internal/scroll.ts`, which
577
+ * says why a reveal cannot be a one-shot and why `onScroll` is not the
578
+ * whole story. A tree grows and shrinks under its own hands: opening a
579
+ * branch is a content that got taller between the ask and the layout, in
580
+ * exactly the way an arriving row is.
581
+ */
582
+ const reveal = useReveal({
583
+ box: scroller,
584
+ rows: rowsRef,
585
+ nodes: rowNodes,
586
+ heights,
587
+ });
588
+
589
+ /** Put a row in view, by the index its call site already has. */
590
+ const revealAt = useCallback(
591
+ (at: number): void => {
592
+ const row = rowsRef.current[at];
593
+ if (row) reveal.to(row.id);
594
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
595
+ // stable handle
596
+ },
597
+ [reveal],
598
+ );
599
+
600
+ /**
601
+ * Re-read the offset the pane is *actually* at.
575
602
  *
576
- * A mounted row can say where it is, and `scrollIntoView` then works
577
- * whatever height it turned out to bewhich is what a tree that is *not*
578
- * virtualizing wants, since nothing has measured its rows. A row that is not
579
- * mounted has no geometry to ask about, and while virtualizing that is the
580
- * normal case, so the height index answers instead: where the row starts,
581
- * and how tall it is or is estimated to be.
603
+ * It moves silently a queued reveal resolves during layout, and an offset
604
+ * the content outgrew or outshrank is re-clamped there and a slice built
605
+ * from the offset before those is drawn where the viewport is not: a blank
606
+ * band where the rows should be, until a scroll of your own re-syncs it by
607
+ * accident.
582
608
  */
583
- const reveal = useCallback((at: number): void => {
609
+ const syncScroll = useCallback((): void => {
584
610
  const box = scroller.current;
585
- const row = rowsRef.current[at];
586
- if (!box || !row) return;
587
- const drawn = rowNodes.current.get(row.id);
588
- if (drawn) {
589
- box.scrollIntoView(drawn.node);
590
- return;
591
- }
592
- const top = heights.offsetAt(at);
593
- const rowH = heights.heightAt(at);
594
- const height = viewRef.current.height;
595
- if (top < box.scrollY) box.scrollTo({ y: top });
596
- else if (height > 0 && top + rowH > box.scrollY + height) {
597
- box.scrollTo({ y: top + rowH - height });
598
- }
599
- }, []);
611
+ if (!box || !virtualizing) return;
612
+ const y = box.scrollY;
613
+ setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
614
+ }, [virtualizing]);
600
615
 
601
616
  /**
602
617
  * Read back what the rows on screen actually laid out at.
@@ -611,8 +626,8 @@ export function Tree<T = TreeItem>({
611
626
  * reports no change, so the second pass over the same rows costs a map walk
612
627
  * and re-renders nothing, and measure → render → measure terminates.
613
628
  */
614
- const measureRows = useCallback((): void => {
615
- if (!virtualizing) return;
629
+ const measureRows = useCallback((): boolean => {
630
+ if (!virtualizing) return false;
616
631
  const box = scroller.current;
617
632
  const rows = rowsRef.current;
618
633
  const idx = heights;
@@ -631,16 +646,30 @@ export function Tree<T = TreeItem>({
631
646
  changed = true;
632
647
  if (at < anchor) shift += height - was;
633
648
  }
634
- if (!changed) return;
649
+ if (!changed) return false;
635
650
  if (shift !== 0 && box) {
636
- box.scrollTo({ y: Math.max(0, box.scrollY + shift) });
651
+ reveal.scrollTo(box.scrollY + shift);
637
652
  }
638
653
  setMeasured((n) => n + 1);
654
+ return true;
639
655
  }, [virtualizing]);
640
656
 
657
+ /**
658
+ * The one tick after layout, and everything that can only be known there:
659
+ * what the rows measured, whether an owed scroll can go further now that
660
+ * the rows it was waiting for are laid out, and where the pane actually
661
+ * ended up. In that order — each step can move the offset the next one
662
+ * reads.
663
+ */
641
664
  useEffect(() => {
642
665
  if (!virtualizing) return undefined;
643
- const id = afterLayout(measureRows);
666
+ const id = afterLayout(() => {
667
+ // `measureRows` first, and its answer handed on: a pass that moved the
668
+ // heights has not settled anything, and an owed scroll judged against
669
+ // the layout it is about to invalidate is not owed any less.
670
+ reveal.retry(measureRows());
671
+ syncScroll();
672
+ });
644
673
  return () => cancelAfterLayout(id);
645
674
  });
646
675
 
@@ -650,9 +679,9 @@ export function Tree<T = TreeItem>({
650
679
  currentRef.current = row.id;
651
680
  if (selected === undefined) setOwnSelected(row.id);
652
681
  onSelect?.(row.id, row.item);
653
- reveal(row.index);
682
+ revealAt(row.index);
654
683
  },
655
- [selected, onSelect, reveal],
684
+ [selected, onSelect, revealAt],
656
685
  );
657
686
 
658
687
  const activate = useCallback(
@@ -799,13 +828,13 @@ export function Tree<T = TreeItem>({
799
828
  scrollToItem: (id) => {
800
829
  const row = rowsRef.current.find((r) => r.id === id);
801
830
  if (!row) return false;
802
- reveal(row.index);
831
+ revealAt(row.index);
803
832
  return true;
804
833
  },
805
834
  handleKey,
806
835
  rows: () => rowsRef.current,
807
836
  }),
808
- [goTo, toggleId, reveal, handleKey, selected, accessors],
837
+ [goTo, toggleId, revealAt, handleKey, selected, accessors],
809
838
  );
810
839
 
811
840
  // --- rendering -----------------------------------------------------------
@@ -1071,9 +1100,20 @@ export function Tree<T = TreeItem>({
1071
1100
  setView((prev) =>
1072
1101
  prev.height === ev.height ? prev : { ...prev, height: ev.height },
1073
1102
  );
1103
+ // The content just changed size, which is both the moment an owed
1104
+ // scroll can reach further than the clamp let it and the moment the
1105
+ // pane may have re-clamped its offset without saying so. It is not a
1106
+ // moment anything can be *settled* in: this runs from layout, a tick
1107
+ // before the pass that reads the rows it just drew back.
1108
+ reveal.retry(virtualizing);
1109
+ syncScroll();
1074
1110
  onViewport?.(ev);
1075
1111
  },
1076
1112
  onScroll: (ev) => {
1113
+ // A scroll this component did not ask for is the user taking over,
1114
+ // and an owed reveal must not yank the tree back out from under them
1115
+ // on the next layout.
1116
+ reveal.heard(ev.scrollY);
1077
1117
  if (virtualizing) {
1078
1118
  setView((prev) =>
1079
1119
  prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY },