@react-x11/components 0.2.0 → 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/src/tree/index.ts CHANGED
@@ -40,7 +40,12 @@ import type { ReactElement, ReactNode, Ref } from 'react';
40
40
  import { createStyles } from 'react-x11/style';
41
41
  import type { StyleProp } from 'react-x11';
42
42
  import { Icon, useDirection, useTheme } from 'react-x11';
43
- import type { DrawnNode, KeyboardEvent, ScrollableNode } from 'react-x11';
43
+ import type {
44
+ DrawnNode,
45
+ KeyboardEvent,
46
+ ScrollableNode,
47
+ Theme,
48
+ } from 'react-x11';
44
49
  import {
45
50
  XK_DOWN,
46
51
  XK_END,
@@ -58,7 +63,23 @@ import type { Host } from './hx.js';
58
63
  // Shared with <Table> — internal, deliberately not a shared *module*; the
59
64
  // header of src/internal/heights.ts says why.
60
65
  import { RowHeights } from '../internal/heights.js';
61
- import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
66
+ import {
67
+ afterLayout,
68
+ cancelAfterLayout,
69
+ cancelLater,
70
+ later,
71
+ } from '../internal/timers.js';
72
+ import type { DelayTick } from '../internal/timers.js';
73
+ import { useReveal } from '../internal/scroll.js';
74
+ import {
75
+ BURST_BUDGET,
76
+ DEFAULT_OVERSCAN,
77
+ DEFAULT_PREFETCH,
78
+ SCROLL_HINT_DELAY_MS,
79
+ SKELETON_THRESHOLD,
80
+ SETTLE_BUDGET,
81
+ useVirtualWindow,
82
+ } from '../internal/window.js';
62
83
  import { typeAheadChar, useTypeAhead } from './internal.js';
63
84
  import {
64
85
  branchEdges,
@@ -106,16 +127,6 @@ const TWISTY = 12;
106
127
  * is half of that, so `size` for one reads as its width. */
107
128
  const TWISTY_GLYPH = 10;
108
129
  const ROW_HEIGHT = 22;
109
- /** Rows kept either side of the viewport, so a fast scroll does not show a
110
- * gap before the next frame catches up. */
111
- const OVERSCAN = 6;
112
- /**
113
- * What to build before the viewport has been measured. `onViewport` cannot
114
- * arrive until layout has run, which is a frame after the first commit, so
115
- * there is always one render that has to guess — and guessing "all of them"
116
- * puts a hundred thousand rows in the tree for a frame.
117
- */
118
- const ASSUMED_ROWS = 40;
119
130
  /**
120
131
  * Where `virtual="auto"` starts virtualizing.
121
132
  *
@@ -167,6 +178,39 @@ const s = createStyles({
167
178
  },
168
179
  subtree: { flexShrink: 0 },
169
180
  spacer: { flexShrink: 0 },
181
+ /** The bar inside a skeleton row — a line of "text" with no text, so a
182
+ * band of placeholders reads as rows arriving rather than a void. */
183
+ skeletonBar: {
184
+ height: 8,
185
+ borderRadius: 4,
186
+ alignSelf: 'center',
187
+ flexShrink: 0,
188
+ },
189
+ /** The box the scroll pane and the fast-scroll pill share — it exists so
190
+ * the pill can float *outside* the pane, where a scroll cannot move it. */
191
+ outer: { flexGrow: 1, minHeight: 0 },
192
+ /** The lane the fast-scroll pill floats in: absolute against the outer
193
+ * box so the pane scrolls under it, full-width so the pill centres
194
+ * itself, and transparent to the pointer so the rows beneath stay
195
+ * clickable. */
196
+ scrollHintLane: {
197
+ position: 'absolute',
198
+ left: 0,
199
+ right: 0,
200
+ bottom: 12,
201
+ flexDirection: 'row',
202
+ justifyContent: 'center',
203
+ pointerEvents: 'none',
204
+ },
205
+ scrollHint: {
206
+ paddingStart: 10,
207
+ paddingEnd: 10,
208
+ paddingTop: 5,
209
+ paddingBottom: 5,
210
+ borderRadius: 12,
211
+ flexDirection: 'row',
212
+ alignItems: 'center',
213
+ },
170
214
  });
171
215
 
172
216
  // --- what the seams are told -----------------------------------------------
@@ -221,6 +265,28 @@ export interface TreeGuideState<T> {
221
265
  height: number;
222
266
  }
223
267
 
268
+ /**
269
+ * What `renderScrollHint` is told: where the viewport is, while a fast
270
+ * scroll is still being caught up with. The top row itself is included so a
271
+ * hint can show what is *at* this position rather than a number.
272
+ */
273
+ export interface TreeScrollHintState<T> {
274
+ /** The first row in view, in draw order. */
275
+ row: TreeRow<T>;
276
+ /** Its position, 1-based — "row `from` of `count`". */
277
+ from: number;
278
+ /** The last row in view, 1-based. */
279
+ to: number;
280
+ /** How many rows the tree is showing. */
281
+ count: number;
282
+ /** How many of the rows in view are still placeholders. */
283
+ pending: number;
284
+ /** When the viewport first stopped being whole, epoch ms — what the
285
+ * show-delay was measured against. `Date.now() - since` is how long the
286
+ * user has been looking at unresolved content. */
287
+ since: number;
288
+ }
289
+
224
290
  /** What `renderSubtree` is told, in `layout="nested"`. */
225
291
  export interface TreeSubtreeState<T> {
226
292
  /** The row the subtree hangs off. Never null: the roots are not a
@@ -317,10 +383,12 @@ export interface TreeProps<T = TreeItem>
317
383
  * virtualizing. Defaults to `rowHeight`.
318
384
  *
319
385
  * Only the rows on screen have ever been laid out, so the scrollbar is
320
- * this guess for everything else, and it converges as you scroll. Set it
321
- * when rows are typically much taller than `rowHeight` a tree of
322
- * two-line rows with the default guess starts with a scrollbar that thinks
323
- * the tree is half its real length.
386
+ * this guess for everything else; it converges as you scroll, and once
387
+ * enough rows have been measured the guess itself is re-learnt from their
388
+ * mean. Set it when rows are typically much taller than `rowHeight` — a
389
+ * tree of two-line rows with the default guess starts with a scrollbar
390
+ * that thinks the tree is half its real length, until the re-learning
391
+ * corrects it.
324
392
  */
325
393
  estimatedRowHeight?: number;
326
394
 
@@ -334,6 +402,15 @@ export interface TreeProps<T = TreeItem>
334
402
  virtual?: boolean | 'auto';
335
403
  /** Rows built either side of the viewport. */
336
404
  overscan?: number;
405
+ /**
406
+ * Rows built *beyond* the overscan while the tree sits idle, per side.
407
+ * Default 40. The pane blits a scroll before React can run, so the only
408
+ * scroll with no blank frame is one that lands on rows already built —
409
+ * this band is that, grown in small steps while nobody is scrolling, and
410
+ * kept behind the viewport so a reversal lands on rows still mounted.
411
+ * `0` turns the band off: the slice is exactly viewport-plus-overscan.
412
+ */
413
+ prefetch?: number;
337
414
 
338
415
  /**
339
416
  * `'flat'` (the default) makes every row a sibling — which is what lets the
@@ -362,6 +439,30 @@ export interface TreeProps<T = TreeItem>
362
439
  renderContent?: (state: TreeRowState<T>, content: ReactNode[]) => ReactNode;
363
440
  /** The subtree container, in `layout="nested"`. */
364
441
  renderSubtree?: (state: TreeSubtreeState<T>, rows: ReactNode) => ReactNode;
442
+ /**
443
+ * The fast-scroll overlay. Shown only while a scroll has outrun the rows
444
+ * far enough that placeholders cover a meaningful part of the viewport —
445
+ * a scroll the tree absorbs within a frame never shows it — and hidden
446
+ * the moment the view is whole again. The default is a pill reading
447
+ * "2,345 / 100,000"; return something else to replace it, or null for no
448
+ * overlay at all.
449
+ */
450
+ renderScrollHint?: (state: TreeScrollHintState<T>) => ReactNode;
451
+ /**
452
+ * How long the viewport must have been showing unresolved content before
453
+ * the overlay appears, in milliseconds. Default 250: a catch-up the next
454
+ * few frames absorb is never announced. `0` shows it the moment a
455
+ * catch-up engages.
456
+ */
457
+ scrollHintDelay?: number;
458
+ /**
459
+ * Tuning for the catch-up pacing — how a scroll that outruns the built
460
+ * rows is absorbed. All optional, all in rows: `threshold` (default 16)
461
+ * is how many rows entering in one render count as a flood, `burst`
462
+ * (default 24) and `settle` (default 48) are the full rows built per
463
+ * render mid-scroll and after it. See the same prop on `<Table>`.
464
+ */
465
+ catchup?: { threshold?: number; burst?: number; settle?: number };
365
466
 
366
467
  styles?: TreeStyles<T>;
367
468
  style?: StyleProp;
@@ -380,6 +481,224 @@ function labelNode(label: ReactNode, style: StyleProp): ReactNode {
380
481
  : label;
381
482
  }
382
483
 
484
+ /**
485
+ * One row, as its own memoized component.
486
+ *
487
+ * The reason is the CPU profile of a fast scroll: every notch re-renders
488
+ * the window, and re-creating a hundred rows' elements per notch — then
489
+ * reconciling them and re-applying identical props to every node — was
490
+ * over half the burst. Every prop here is identity-stable across a scroll
491
+ * render (the row model is memoized, the accessors and handlers are stable
492
+ * callbacks), so React bails out on the rows that did not change and a
493
+ * notch pays only for the rows it brought in.
494
+ */
495
+ interface TreeRowViewProps<T> {
496
+ row: TreeRow<T>;
497
+ isSelected: boolean;
498
+ indent: number;
499
+ rowHeight: number;
500
+ rtl: boolean;
501
+ theme: Theme;
502
+ renderToggle?: (state: TreeToggleState<T>) => ReactNode;
503
+ renderGuide?: (state: TreeGuideState<T>) => ReactNode;
504
+ renderLabel?: (state: TreeRowState<T>) => ReactNode;
505
+ renderContent?: (state: TreeRowState<T>, content: ReactNode[]) => ReactNode;
506
+ rowStyle: TreeStyles<T>['row'];
507
+ guideStyle: TreeStyles<T>['guide'];
508
+ toggleStyle: StyleProp | undefined;
509
+ labelStyle: StyleProp | undefined;
510
+ getLabel: (item: T) => ReactNode;
511
+ onToggle: (id: TreeItemId, item: T, open?: boolean) => void;
512
+ onGo: (row: TreeRow<T>) => void;
513
+ onOpen: (row: TreeRow<T>) => void;
514
+ register: (id: TreeItemId, at: number, node: DrawnNode | null) => void;
515
+ }
516
+
517
+ function TreeRowView<T>(props: TreeRowViewProps<T>): ReactElement {
518
+ const {
519
+ row,
520
+ isSelected,
521
+ indent,
522
+ rowHeight,
523
+ rtl,
524
+ theme,
525
+ renderToggle,
526
+ renderGuide,
527
+ renderLabel,
528
+ renderContent,
529
+ rowStyle,
530
+ guideStyle,
531
+ toggleStyle,
532
+ labelStyle,
533
+ getLabel,
534
+ onToggle,
535
+ onGo,
536
+ onOpen,
537
+ register,
538
+ } = props;
539
+ const color = row.disabled
540
+ ? theme.textMuted
541
+ : isSelected
542
+ ? theme.hoverText
543
+ : theme.text;
544
+ const state: TreeRowState<T> = {
545
+ ...row,
546
+ selected: isSelected,
547
+ color,
548
+ toggle: (open?: boolean) => onToggle(row.id, row.item, open),
549
+ select: () => onGo(row),
550
+ };
551
+
552
+ const content: ReactNode[] = [];
553
+
554
+ // The indent. With no guide seam it is one padding value rather than
555
+ // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
556
+ // per row to draw nothing.
557
+ if (renderGuide && row.depth > 0) {
558
+ const edges = branchEdges(row);
559
+ for (let level = 0; level < row.depth; level++) {
560
+ const guide: TreeGuideState<T> = {
561
+ row: state,
562
+ level,
563
+ continues: edges[level],
564
+ own: level === row.depth - 1,
565
+ width: indent,
566
+ height: rowHeight,
567
+ };
568
+ content.push(
569
+ hx(
570
+ 'box',
571
+ {
572
+ key: `guide${level}`,
573
+ style: [
574
+ s.guide,
575
+ { width: indent },
576
+ typeof guideStyle === 'function' ? guideStyle(guide) : guideStyle,
577
+ ],
578
+ },
579
+ renderGuide(guide),
580
+ ),
581
+ );
582
+ }
583
+ }
584
+
585
+ const toggleState: TreeToggleState<T> = { ...state, size: TWISTY_GLYPH };
586
+ content.push(
587
+ hx(
588
+ 'box',
589
+ {
590
+ key: 'toggle',
591
+ style: [s.twisty, toggleStyle],
592
+ // The twisty is its own hit target: clicking it opens the branch
593
+ // without moving the selection, the way a file browser lets you
594
+ // peek inside a folder you have not chosen.
595
+ onClick: row.branch
596
+ ? (ev) => {
597
+ ev.stopPropagation();
598
+ onToggle(row.id, row.item);
599
+ }
600
+ : undefined,
601
+ },
602
+ renderToggle
603
+ ? renderToggle(toggleState)
604
+ : row.branch
605
+ ? React.createElement(Icon, {
606
+ name: row.open
607
+ ? 'chevronDown'
608
+ : rtl
609
+ ? 'chevronLeft'
610
+ : 'chevronRight',
611
+ size: TWISTY_GLYPH,
612
+ // dimmer than the label on a resting row, and the row's own
613
+ // ink once it is selected
614
+ style: isSelected ? undefined : { color: theme.textMuted },
615
+ })
616
+ : null,
617
+ ),
618
+ );
619
+
620
+ content.push(
621
+ renderLabel
622
+ ? // Keyed here rather than by the app, for the reason `renderSubtree`
623
+ // is: the label sits in an array beside the guides and the twisty,
624
+ // and "add a key to the box you return" is not something a render
625
+ // prop should have to know.
626
+ React.createElement(
627
+ React.Fragment,
628
+ { key: 'label' },
629
+ renderLabel(state),
630
+ )
631
+ : labelNode(getLabel(row.item), [s.label, labelStyle]),
632
+ );
633
+
634
+ return hx(
635
+ 'box',
636
+ {
637
+ role: 'treeitem',
638
+ 'aria-level': row.depth + 1,
639
+ 'aria-selected': isSelected,
640
+ 'aria-expanded': row.branch ? row.open : undefined,
641
+ 'aria-posinset': row.posInSet,
642
+ 'aria-setsize': row.setSize,
643
+ // `disabled` rather than `aria-disabled`: on a react-x11 node it is
644
+ // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
645
+ // selects the `:disabled` style block — and there is no aria spelling
646
+ // of it to write instead.
647
+ disabled: row.disabled || undefined,
648
+ // The index the row was drawn at travels with the node, so measuring
649
+ // does not have to search a hundred thousand rows for where it is.
650
+ // It can go stale — the rows may move before the tick that measures —
651
+ // and both this and the height index check it rather than trust it.
652
+ ref: (node: DrawnNode | null) => {
653
+ register(row.id, row.index, node);
654
+ },
655
+ onClick: (ev) => {
656
+ if (row.disabled) return;
657
+ onGo(row);
658
+ // Select on the first click, open on the second — the gesture every
659
+ // file list has. `detail` is the click count the renderer already
660
+ // counts for text selection.
661
+ if (ev.detail === 2) onOpen(row);
662
+ },
663
+ style: [
664
+ s.row,
665
+ // A floor, not a height. The row grows to whatever its content
666
+ // needs — a wrapped label, two lines, a thumbnail — and the height
667
+ // index reads back what it actually became.
668
+ { minHeight: rowHeight },
669
+ // The indent is what says "inside", so it is measured from the edge
670
+ // the row's label begins at.
671
+ { paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
672
+ {
673
+ backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
674
+ // The row's ink, said once: `color` inherits, so the label takes
675
+ // it without being handed it.
676
+ color,
677
+ },
678
+ !row.disabled && {
679
+ ':hover': {
680
+ backgroundColor: isSelected
681
+ ? theme.hoverBackground
682
+ : theme.surfaceHover,
683
+ },
684
+ // The selection only moves on the release, and `:active` marks
685
+ // the whole press chain, so a press on the label or the twisty
686
+ // still darkens the row it is in.
687
+ ':active': {
688
+ backgroundColor: isSelected
689
+ ? theme.accentActive
690
+ : theme.surfaceActive,
691
+ },
692
+ },
693
+ typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
694
+ ],
695
+ },
696
+ renderContent ? renderContent(state, content) : content,
697
+ );
698
+ }
699
+
700
+ const MemoTreeRow = React.memo(TreeRowView) as typeof TreeRowView;
701
+
383
702
  /**
384
703
  * `<Tree items />` — a disclosure tree.
385
704
  *
@@ -426,13 +745,17 @@ export function Tree<T = TreeItem>({
426
745
  rowHeight = ROW_HEIGHT,
427
746
  estimatedRowHeight,
428
747
  virtual = 'auto',
429
- overscan = OVERSCAN,
748
+ overscan = DEFAULT_OVERSCAN,
749
+ prefetch = DEFAULT_PREFETCH,
430
750
  layout = 'flat',
431
751
  renderToggle,
432
752
  renderGuide,
433
753
  renderLabel,
434
754
  renderContent,
435
755
  renderSubtree,
756
+ renderScrollHint,
757
+ scrollHintDelay = SCROLL_HINT_DELAY_MS,
758
+ catchup,
436
759
  styles,
437
760
  style,
438
761
  ref,
@@ -449,6 +772,7 @@ export function Tree<T = TreeItem>({
449
772
  onViewport,
450
773
  ...boxProps
451
774
  }: TreeProps<T>): ReactElement {
775
+ (globalThis as any).__renders = ((globalThis as any).__renders ?? 0) + 1;
452
776
  const theme = useTheme();
453
777
  const rtl = useDirection() === 'rtl';
454
778
  const [ownExpanded, setOwnExpanded] = useState<ReadonlySet<TreeItemId>>(
@@ -457,7 +781,6 @@ export function Tree<T = TreeItem>({
457
781
  const [ownSelected, setOwnSelected] = useState<TreeItemId | null>(
458
782
  defaultSelected ?? null,
459
783
  );
460
- const [view, setView] = useState({ top: 0, height: 0 });
461
784
  // Bumped by a measurement pass that found a row taller or shorter than the
462
785
  // index believed. It is the only reason the component re-renders for a
463
786
  // measurement, and a pass that finds nothing new does not bump it, which is
@@ -511,8 +834,6 @@ export function Tree<T = TreeItem>({
511
834
  rowsRef.current = rows;
512
835
  const itemsRef = useRef(items);
513
836
  itemsRef.current = items;
514
- const viewRef = useRef(view);
515
- viewRef.current = view;
516
837
 
517
838
  const virtualizing =
518
839
  layout === 'flat' &&
@@ -525,30 +846,27 @@ export function Tree<T = TreeItem>({
525
846
  const index = heights;
526
847
  index.sync(rows, estimate);
527
848
 
528
- // The slice worth building: what is on screen, plus a little either side.
529
- // Which rows those are is a question for the height index now — with rows
530
- // of different heights there is no division that answers it.
531
- const first = virtualizing
532
- ? Math.max(0, index.indexAt(view.top) - overscan)
533
- : 0;
534
- let last = rows.length;
535
- if (virtualizing) {
536
- if (view.height > 0) {
537
- last = Math.min(
538
- rows.length,
539
- index.indexAt(view.top + view.height) + 1 + overscan,
540
- );
541
- } else {
542
- // Before the first layout there is no viewport to measure against, and
543
- // guessing "all of them" would put a hundred thousand rows in the tree
544
- // for a frame.
545
- last = Math.min(rows.length, first + ASSUMED_ROWS);
546
- }
547
- }
548
- /** Where the slice starts, and how much of the list is below it the two
549
- * spacers that keep the scrollbar measuring the whole tree. */
550
- const above = virtualizing ? index.offsetAt(first) : 0;
551
- const below = virtualizing ? index.total() - index.offsetAt(last) : 0;
849
+ /** The viewport, and the slice worth building from it the machinery
850
+ * shared with `<Table>` (`../internal/window.ts`). */
851
+ const win = useVirtualWindow({
852
+ box: scroller,
853
+ heights,
854
+ rows,
855
+ // tree rows are always measured, so the idle band above the viewport
856
+ // only re-builds territory already visited — see `exact` on the inputs
857
+ exact: false,
858
+ virtualizing,
859
+ overscan,
860
+ prefetch,
861
+ threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
862
+ burstBudget: catchup?.burst ?? BURST_BUDGET,
863
+ settleBudget: catchup?.settle ?? SETTLE_BUDGET,
864
+ });
865
+ const { view, viewRef } = win;
866
+ /** Whether the fast-scroll pill is up — kept across renders so it does not
867
+ * flicker through a catch-up, only appearing and disappearing once. */
868
+ const hintShown = useRef(false);
869
+ const { first, last, above, below } = win.slice;
552
870
 
553
871
  const setExpandedSet = useCallback(
554
872
  (next: ReadonlySet<TreeItemId>, change: TreeExpandChange<T>): void => {
@@ -571,32 +889,34 @@ export function Tree<T = TreeItem>({
571
889
  );
572
890
 
573
891
  /**
574
- * Put a row in view.
575
- *
576
- * A mounted row can say where it is, and `scrollIntoView` then works
577
- * whatever height it turned out to be which 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.
892
+ * The scroll the tree owes a row, and the pane's real offset read back
893
+ * after every layout — the two halves of `../internal/scroll.ts`, which
894
+ * says why a reveal cannot be a one-shot and why `onScroll` is not the
895
+ * whole story. A tree grows and shrinks under its own hands: opening a
896
+ * branch is a content that got taller between the ask and the layout, in
897
+ * exactly the way an arriving row is.
582
898
  */
583
- const reveal = useCallback((at: number): void => {
584
- 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
- }, []);
899
+ const reveal = useReveal({
900
+ box: scroller,
901
+ rows: rowsRef,
902
+ nodes: rowNodes,
903
+ heights,
904
+ });
905
+
906
+ /** Put a row in view, by the index its call site already has. */
907
+ const revealAt = useCallback(
908
+ (at: number): void => {
909
+ const row = rowsRef.current[at];
910
+ if (row) reveal.to(row.id);
911
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
912
+ // stable handle
913
+ },
914
+ [reveal],
915
+ );
916
+
917
+ /** Re-read the offset the pane is *actually* at — the window's `sync`; see
918
+ * `../internal/window.ts` for why the pane moves silently. */
919
+ const syncScroll = win.sync;
600
920
 
601
921
  /**
602
922
  * Read back what the rows on screen actually laid out at.
@@ -611,8 +931,8 @@ export function Tree<T = TreeItem>({
611
931
  * reports no change, so the second pass over the same rows costs a map walk
612
932
  * and re-renders nothing, and measure → render → measure terminates.
613
933
  */
614
- const measureRows = useCallback((): void => {
615
- if (!virtualizing) return;
934
+ const measureRows = useCallback((): boolean => {
935
+ if (!virtualizing) return false;
616
936
  const box = scroller.current;
617
937
  const rows = rowsRef.current;
618
938
  const idx = heights;
@@ -631,17 +951,81 @@ export function Tree<T = TreeItem>({
631
951
  changed = true;
632
952
  if (at < anchor) shift += height - was;
633
953
  }
634
- if (!changed) return;
635
- if (shift !== 0 && box) {
636
- box.scrollTo({ y: Math.max(0, box.scrollY + shift) });
637
- }
954
+ if (!changed) return false;
955
+ // A debt, not a one-shot: the pane clamps against the last layout's
956
+ // content height, so a shift from rows measured above the viewport can
957
+ // land short until the layout that admits the growth has run.
958
+ reveal.nudge(shift);
638
959
  setMeasured((n) => n + 1);
960
+ return true;
639
961
  }, [virtualizing]);
640
962
 
963
+ /**
964
+ * Let the estimate learn from the rows that have been measured — the
965
+ * scrollbar of a measured tree starts as a guess times the row count, and
966
+ * the measured mean is a far better guess for the rows not yet seen. Idle
967
+ * only: every unmeasured offset moves when it applies, and the anchor
968
+ * arithmetic keeping the screen still is `measureRows`'s.
969
+ */
970
+ const adaptEstimate = useCallback((): boolean => {
971
+ if (!virtualizing) return false;
972
+ const box = scroller.current;
973
+ if (!box) return false;
974
+ const anchor = heights.indexAt(box.scrollY);
975
+ const before = heights.offsetAt(anchor);
976
+ if (!heights.adapt()) return false;
977
+ reveal.nudge(heights.offsetAt(anchor) - before);
978
+ setMeasured((n) => n + 1);
979
+ return true;
980
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
981
+ // `reveal` are stable instances
982
+ }, [virtualizing]);
983
+
984
+ /**
985
+ * The one tick after layout, and everything that can only be known there:
986
+ * what the rows measured, whether an owed scroll can go further now that
987
+ * the rows it was waiting for are laid out, and where the pane actually
988
+ * ended up. In that order — each step can move the offset the next one
989
+ * reads.
990
+ */
991
+ /** Whether some drawn row has no size yet — a commit can land between
992
+ * frame flushes, and a measure pass over it reads zeros. */
993
+ const rowsPendingLayout = useCallback((): boolean => {
994
+ const rows = rowsRef.current;
995
+ for (const [id, { node, at }] of rowNodes.current) {
996
+ if (rows[at]?.id === id && !(node.abs.height > 0)) return true;
997
+ }
998
+ return false;
999
+ }, []);
1000
+
641
1001
  useEffect(() => {
642
1002
  if (!virtualizing) return undefined;
643
- const id = afterLayout(measureRows);
644
- return () => cancelAfterLayout(id);
1003
+ let look: DelayTick = null;
1004
+ let tries = 0;
1005
+ const pass = (): void => {
1006
+ (globalThis as any).__ticks = ((globalThis as any).__ticks ?? 0) + 1;
1007
+ // `measureRows` first, and its answer handed on: a pass that moved the
1008
+ // heights has not settled anything, and an owed scroll judged against
1009
+ // the layout it is about to invalidate is not owed any less. During a
1010
+ // flick nothing is measured at all — every correction at that speed is
1011
+ // invalidated by the next event — and the settle tick that follows any
1012
+ // burst is where the deferred passes catch up.
1013
+ const moved = win.fast() ? false : measureRows();
1014
+ const adapted = !win.scrolling() && adaptEstimate();
1015
+ reveal.retry(moved || adapted);
1016
+ syncScroll();
1017
+ // A commit can land between frame flushes: its rows report zero size
1018
+ // until the flush, this tick has already run, and nothing else would
1019
+ // come back for them — a window that just finished growing renders
1020
+ // nothing further, and the missed measurements would stand for good.
1021
+ // Look again, briefly, while any drawn row is still unsized.
1022
+ if (rowsPendingLayout() && tries++ < 8) look = later(pass, 16);
1023
+ };
1024
+ const id = afterLayout(pass);
1025
+ return () => {
1026
+ cancelAfterLayout(id);
1027
+ cancelLater(look);
1028
+ };
645
1029
  });
646
1030
 
647
1031
  const goTo = useCallback(
@@ -650,9 +1034,9 @@ export function Tree<T = TreeItem>({
650
1034
  currentRef.current = row.id;
651
1035
  if (selected === undefined) setOwnSelected(row.id);
652
1036
  onSelect?.(row.id, row.item);
653
- reveal(row.index);
1037
+ revealAt(row.index);
654
1038
  },
655
- [selected, onSelect, reveal],
1039
+ [selected, onSelect, revealAt],
656
1040
  );
657
1041
 
658
1042
  const activate = useCallback(
@@ -799,13 +1183,13 @@ export function Tree<T = TreeItem>({
799
1183
  scrollToItem: (id) => {
800
1184
  const row = rowsRef.current.find((r) => r.id === id);
801
1185
  if (!row) return false;
802
- reveal(row.index);
1186
+ revealAt(row.index);
803
1187
  return true;
804
1188
  },
805
1189
  handleKey,
806
1190
  rows: () => rowsRef.current,
807
1191
  }),
808
- [goTo, toggleId, reveal, handleKey, selected, accessors],
1192
+ [goTo, toggleId, revealAt, handleKey, selected, accessors],
809
1193
  );
810
1194
 
811
1195
  // --- rendering -----------------------------------------------------------
@@ -813,7 +1197,103 @@ export function Tree<T = TreeItem>({
813
1197
  const rowStyleProp = styles?.row;
814
1198
  const guideStyleProp = styles?.guide;
815
1199
 
1200
+ // The row component's stable half — `MemoTreeRow` bails out of a
1201
+ // re-render only if every prop kept its identity, and this one would
1202
+ // otherwise be rebuilt per row per render.
1203
+ const registerRow = useCallback(
1204
+ (id: TreeItemId, at: number, node: DrawnNode | null): void => {
1205
+ if (node) rowNodes.current.set(id, { node, at });
1206
+ else rowNodes.current.delete(id);
1207
+ },
1208
+ [],
1209
+ );
1210
+
1211
+ /**
1212
+ * The row *elements*, reused by identity while nothing they depend on has
1213
+ * changed. The memo already skips re-rendering an unchanged row, but the
1214
+ * skip still costs a `createElement` and a props compare per row per
1215
+ * notch — the burst profile put bare `createElement` at a tenth of a
1216
+ * flick's CPU. Handing React the identical element object instead takes
1217
+ * the cheapest path it has: the fiber is reused with no compare at all.
1218
+ */
1219
+ const rowElems = useRef(
1220
+ new Map<
1221
+ TreeItemId,
1222
+ { row: TreeRow<T>; selected: boolean; el: ReactElement }
1223
+ >(),
1224
+ );
1225
+ const rowElemDeps = useRef<readonly unknown[]>([]);
1226
+ {
1227
+ const deps = [
1228
+ indent,
1229
+ rowHeight,
1230
+ rtl,
1231
+ theme,
1232
+ renderToggle,
1233
+ renderGuide,
1234
+ renderLabel,
1235
+ renderContent,
1236
+ rowStyleProp,
1237
+ guideStyleProp,
1238
+ styles?.toggle,
1239
+ styles?.label,
1240
+ accessors,
1241
+ toggleId,
1242
+ goTo,
1243
+ activate,
1244
+ registerRow,
1245
+ ];
1246
+ const prev = rowElemDeps.current;
1247
+ if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
1248
+ rowElems.current.clear();
1249
+ rowElemDeps.current = deps;
1250
+ }
1251
+ }
1252
+
816
1253
  const renderOneRow = (row: TreeRow<T>): ReactElement => {
1254
+ const isSelected = row.id === current;
1255
+ const cached = rowElems.current.get(row.id);
1256
+ if (cached && cached.row === row && cached.selected === isSelected) {
1257
+ return cached.el;
1258
+ }
1259
+ const el = React.createElement(
1260
+ MemoTreeRow as (p: TreeRowViewProps<T>) => ReactElement,
1261
+ {
1262
+ key: String(row.id),
1263
+ row,
1264
+ isSelected,
1265
+ indent,
1266
+ rowHeight,
1267
+ rtl,
1268
+ theme,
1269
+ renderToggle,
1270
+ renderGuide,
1271
+ renderLabel,
1272
+ renderContent,
1273
+ rowStyle: rowStyleProp,
1274
+ guideStyle: guideStyleProp,
1275
+ toggleStyle: styles?.toggle,
1276
+ labelStyle: styles?.label,
1277
+ getLabel: accessors.getLabel,
1278
+ onToggle: toggleId,
1279
+ onGo: goTo,
1280
+ onOpen: activate,
1281
+ register: registerRow,
1282
+ },
1283
+ );
1284
+ rowElems.current.set(row.id, { row, selected: isSelected, el });
1285
+ return el;
1286
+ };
1287
+
1288
+ /**
1289
+ * A row the window said not to build in full yet: the box at its indexed
1290
+ * height and none of its content — no guides, no twisty, no label — so
1291
+ * the commit answering a flood lands frames before the full rows could.
1292
+ * `styles.row` still applies, so row backgrounds hold. Not registered in
1293
+ * `rowNodes`: a skeleton must not be measured into the height index, and
1294
+ * cannot satisfy a reveal.
1295
+ */
1296
+ const renderSkeletonRow = (row: TreeRow<T>): ReactElement => {
817
1297
  const isSelected = row.id === current;
818
1298
  const color = row.disabled
819
1299
  ? theme.textMuted
@@ -827,158 +1307,37 @@ export function Tree<T = TreeItem>({
827
1307
  toggle: (open?: boolean) => toggleId(row.id, row.item, open),
828
1308
  select: () => goTo(row),
829
1309
  };
830
-
831
- const content: ReactNode[] = [];
832
-
833
- // The indent. With no guide seam it is one padding value rather than
834
- // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
835
- // per row to draw nothing.
836
- if (renderGuide && row.depth > 0) {
837
- const edges = branchEdges(row);
838
- for (let level = 0; level < row.depth; level++) {
839
- const guide: TreeGuideState<T> = {
840
- row: state,
841
- level,
842
- continues: edges[level],
843
- own: level === row.depth - 1,
844
- width: indent,
845
- height: rowHeight,
846
- };
847
- content.push(
848
- hx(
849
- 'box',
850
- {
851
- key: `guide${level}`,
852
- style: [
853
- s.guide,
854
- { width: indent },
855
- typeof guideStyleProp === 'function'
856
- ? guideStyleProp(guide)
857
- : guideStyleProp,
858
- ],
859
- },
860
- renderGuide(guide),
861
- ),
862
- );
863
- }
864
- }
865
-
866
- const toggleState: TreeToggleState<T> = { ...state, size: TWISTY_GLYPH };
867
- content.push(
868
- hx(
869
- 'box',
870
- {
871
- key: 'toggle',
872
- style: [s.twisty, styles?.toggle],
873
- // The twisty is its own hit target: clicking it opens the branch
874
- // without moving the selection, the way a file browser lets you
875
- // peek inside a folder you have not chosen.
876
- onClick: row.branch
877
- ? (ev) => {
878
- ev.stopPropagation();
879
- toggleId(row.id, row.item);
880
- }
881
- : undefined,
882
- },
883
- renderToggle
884
- ? renderToggle(toggleState)
885
- : row.branch
886
- ? React.createElement(Icon, {
887
- name: row.open
888
- ? 'chevronDown'
889
- : rtl
890
- ? 'chevronLeft'
891
- : 'chevronRight',
892
- size: TWISTY_GLYPH,
893
- // dimmer than the label on a resting row, and the row's own
894
- // ink once it is selected
895
- style: isSelected ? undefined : { color: theme.textMuted },
896
- })
897
- : null,
898
- ),
899
- );
900
-
901
- content.push(
902
- renderLabel
903
- ? // Keyed here rather than by the app, for the reason `renderSubtree`
904
- // is: the label sits in an array beside the guides and the twisty,
905
- // and "add a key to the box you return" is not something a render
906
- // prop should have to know.
907
- React.createElement(
908
- React.Fragment,
909
- { key: 'label' },
910
- renderLabel(state),
911
- )
912
- : labelNode(accessors.getLabel(row.item), [s.label, styles?.label]),
913
- );
914
-
915
1310
  return hx(
916
1311
  'box',
917
1312
  {
918
1313
  key: String(row.id),
919
- role: 'treeitem',
920
- 'aria-level': row.depth + 1,
921
- 'aria-selected': isSelected,
922
- 'aria-expanded': row.branch ? row.open : undefined,
923
- 'aria-posinset': row.posInSet,
924
- 'aria-setsize': row.setSize,
925
- // `disabled` rather than `aria-disabled`: on a react-x11 node it is
926
- // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
927
- // selects the `:disabled` style block — and there is no aria spelling
928
- // of it to write instead.
929
- disabled: row.disabled || undefined,
930
- // The index the row was drawn at travels with the node, so measuring
931
- // does not have to search a hundred thousand rows for where it is.
932
- // It can go stale — the rows may move before the tick that measures —
933
- // and both this and the height index check it rather than trust it.
934
- ref: (node: DrawnNode | null) => {
935
- if (node) rowNodes.current.set(row.id, { node, at: row.index });
936
- else rowNodes.current.delete(row.id);
937
- },
938
- onClick: (ev) => {
939
- if (row.disabled) return;
940
- goTo(row);
941
- // Select on the first click, open on the second — the gesture every
942
- // file list has. `detail` is the click count the renderer already
943
- // counts for text selection.
944
- if (ev.detail === 2) activate(row);
945
- },
1314
+ 'aria-hidden': true,
946
1315
  style: [
947
1316
  s.row,
948
- // A floor, not a height. The row grows to whatever its content
949
- // needs a wrapped label, two lines, a thumbnail — and the height
950
- // index reads back what it actually became.
951
- { minHeight: rowHeight },
952
- // The indent is what says "inside", so it is measured from the edge
953
- // the row's label begins at.
954
- { paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
1317
+ // Exactly what the index believes, so the spacers and the
1318
+ // scrollbar agree with the rows on where everything is.
1319
+ { height: index.heightAt(row.index) },
955
1320
  {
956
1321
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
957
- // The row's ink, said once: `color` inherits, so the label takes
958
- // it without being handed it.
959
- color,
960
- },
961
- !row.disabled && {
962
- ':hover': {
963
- backgroundColor: isSelected
964
- ? theme.hoverBackground
965
- : theme.surfaceHover,
966
- },
967
- // The selection only moves on the release, and `:active` marks
968
- // the whole press chain, so a press on the label or the twisty
969
- // still darkens the row it is in.
970
- ':active': {
971
- backgroundColor: isSelected
972
- ? theme.accentActive
973
- : theme.surfaceActive,
974
- },
975
1322
  },
976
1323
  typeof rowStyleProp === 'function'
977
1324
  ? rowStyleProp(state)
978
1325
  : rowStyleProp,
979
1326
  ],
980
1327
  },
981
- renderContent ? renderContent(state, content) : content,
1328
+ // A line of "text" with no text, at the row's own indent, so a band
1329
+ // of placeholders reads as the tree arriving rather than a void.
1330
+ hx('box', {
1331
+ key: 'bar',
1332
+ style: [
1333
+ s.skeletonBar,
1334
+ {
1335
+ width: 72 + ((row.index * 37) % 89),
1336
+ marginStart: 4 + row.depth * indent + TWISTY + 4,
1337
+ backgroundColor: theme.track,
1338
+ },
1339
+ ],
1340
+ }),
982
1341
  );
983
1342
  };
984
1343
 
@@ -1026,7 +1385,13 @@ export function Tree<T = TreeItem>({
1026
1385
  }),
1027
1386
  );
1028
1387
  }
1029
- for (let i = first; i < last; i++) body.push(renderOneRow(rows[i]));
1388
+ for (let i = first; i < last; i++) {
1389
+ body.push(
1390
+ win.skeletons.has(rows[i].id)
1391
+ ? renderSkeletonRow(rows[i])
1392
+ : renderOneRow(rows[i]),
1393
+ );
1394
+ }
1030
1395
  if (virtualizing && last < rows.length) {
1031
1396
  body.push(
1032
1397
  hx('box', {
@@ -1035,53 +1400,155 @@ export function Tree<T = TreeItem>({
1035
1400
  }),
1036
1401
  );
1037
1402
  }
1403
+ // Rows that left the window leave the cache too, once it has grown
1404
+ // well past the window — a scrub across a long list would otherwise
1405
+ // hold an element for every row it passed.
1406
+ if (rowElems.current.size > (last - first) * 3 + 64) {
1407
+ rowElems.current.clear();
1408
+ }
1038
1409
  }
1039
1410
 
1411
+ /**
1412
+ * The fast-scroll overlay — shown only while placeholders cover enough of
1413
+ * the viewport that the user would otherwise be looking at blank rows.
1414
+ * The half-viewport threshold keeps a near-miss quiet: a scroll the next
1415
+ * frame will absorb is not worth announcing. Once up it stays until the
1416
+ * view is whole again, so it does not flicker through the catch-up.
1417
+ *
1418
+ * A sibling of the scroll pane, never a child: everything inside the pane
1419
+ * — absolute children included — is shifted by the scroll, so a pill in
1420
+ * there rides away with the very flick it is meant to narrate. Outside,
1421
+ * it is painted after the pane on every repaint frame, which is what a
1422
+ * scrub produces (a jump past the viewport cannot take the blit fast
1423
+ * path), so it stays put while the content flies.
1424
+ */
1425
+ let scrollHint: ReactNode = null;
1426
+ if (virtualizing && rows.length > 0 && view.height > 0) {
1427
+ const vFirst = index.indexAt(view.top);
1428
+ const vLast = Math.min(
1429
+ rows.length - 1,
1430
+ index.indexAt(view.top + view.height),
1431
+ );
1432
+ // Two ways in: placeholders covering enough of the viewport that it
1433
+ // would otherwise read as blank, or a scrub — the window teleporting
1434
+ // while the burst is still in flight, where every commit chases a
1435
+ // viewport that has already left and nothing useful can be on screen.
1436
+ // Either way only once the catch-up has already *lasted*: a jump the
1437
+ // next few frames absorb is not worth announcing, so the pill waits
1438
+ // out the show-delay against the catch-up clock. Latched once
1439
+ // triggered: `pending` bounces to zero between catch-up commits, and a
1440
+ // pill that blinked with it would read as a glitch. It goes when the
1441
+ // burst does.
1442
+ const engaged =
1443
+ (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
1444
+ (win.jumped && win.scrolling());
1445
+ const lasted =
1446
+ win.catchupSince !== null &&
1447
+ Date.now() - win.catchupSince >= scrollHintDelay;
1448
+ const show =
1449
+ (engaged && lasted) ||
1450
+ (hintShown.current && (win.pending > 0 || win.scrolling()));
1451
+ hintShown.current = show;
1452
+ if (show) {
1453
+ const hintState: TreeScrollHintState<T> = {
1454
+ row: rows[vFirst],
1455
+ from: vFirst + 1,
1456
+ to: vLast + 1,
1457
+ count: rows.length,
1458
+ pending: win.pending,
1459
+ since: win.catchupSince ?? Date.now(),
1460
+ };
1461
+ const content = renderScrollHint
1462
+ ? renderScrollHint(hintState)
1463
+ : hx(
1464
+ 'text',
1465
+ { style: { fontSize: 11, color: theme.hoverText } },
1466
+ `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`,
1467
+ );
1468
+ if (content !== null && content !== undefined && content !== false) {
1469
+ scrollHint = hx(
1470
+ 'box',
1471
+ {
1472
+ key: 'scroll-hint',
1473
+ // The pill duplicates what the scrollbar already tells an
1474
+ // assistive technology, and it comes and goes with the
1475
+ // catch-up — chatter, not content.
1476
+ 'aria-hidden': true,
1477
+ style: s.scrollHintLane,
1478
+ },
1479
+ hx(
1480
+ 'box',
1481
+ {
1482
+ style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
1483
+ },
1484
+ content,
1485
+ ),
1486
+ );
1487
+ }
1488
+ }
1489
+ } else {
1490
+ hintShown.current = false;
1491
+ }
1492
+
1493
+ // The wrapper exists for the overlay: the scroll pane keeps the role, the
1494
+ // focus, the refs and the events — everything a `<Tree>` has always put
1495
+ // on its root — and the caller's `style` lands out here, where the
1496
+ // tree's place in the layout is decided.
1040
1497
  return hx(
1041
1498
  'box',
1042
- {
1043
- theme,
1044
- role: 'tree',
1045
- // The tree takes the focus, not the row — see the doc comment.
1046
- focusable: true,
1047
- ...boxProps,
1048
- ref: scroller,
1049
- style: [s.root, style],
1050
- /**
1051
- * `preventDefault` is the load-bearing half.
1052
- *
1053
- * The tree's root is a scroll container **and** the focused node, and a
1054
- * focused scroller has default key actions of its own: Down and Up
1055
- * scroll by a wheel notch, the Page keys by a viewport, Home and End to
1056
- * the ends, Space by a page. Without this, every arrow did both — moved
1057
- * the selection *and* scrolled the list under it which reads as the
1058
- * tree scrolling whenever you use the keyboard rather than only when
1059
- * the selection would otherwise leave the viewport.
1060
- *
1061
- * `handleKey` reports whether the tree took the key, so a key it did
1062
- * not take (a letter that matched nothing) still gets the default.
1063
- */
1064
- onKeyDown: (ev) => {
1065
- if (handleKey(ev)) ev.preventDefault();
1066
- },
1067
- // Layout, not scrolling, is what first tells a list how much of it is
1068
- // worth building — and it is also where a page key gets its distance,
1069
- // so this is measured whether or not the tree virtualizes.
1070
- onViewport: (ev) => {
1071
- setView((prev) =>
1072
- prev.height === ev.height ? prev : { ...prev, height: ev.height },
1073
- );
1074
- onViewport?.(ev);
1075
- },
1076
- onScroll: (ev) => {
1077
- if (virtualizing) {
1078
- setView((prev) =>
1079
- prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY },
1080
- );
1081
- }
1082
- onScroll?.(ev);
1499
+ { style: [s.outer, style] },
1500
+ hx(
1501
+ 'box',
1502
+ {
1503
+ theme,
1504
+ role: 'tree',
1505
+ // The tree takes the focus, not the row — see the doc comment.
1506
+ focusable: true,
1507
+ ...boxProps,
1508
+ ref: scroller,
1509
+ style: s.root,
1510
+ /**
1511
+ * `preventDefault` is the load-bearing half.
1512
+ *
1513
+ * The tree's root is a scroll container **and** the focused node, and a
1514
+ * focused scroller has default key actions of its own: Down and Up
1515
+ * scroll by a wheel notch, the Page keys by a viewport, Home and End to
1516
+ * the ends, Space by a page. Without this, every arrow did both — moved
1517
+ * the selection *and* scrolled the list under it — which reads as the
1518
+ * tree scrolling whenever you use the keyboard rather than only when
1519
+ * the selection would otherwise leave the viewport.
1520
+ *
1521
+ * `handleKey` reports whether the tree took the key, so a key it did
1522
+ * not take (a letter that matched nothing) still gets the default.
1523
+ */
1524
+ onKeyDown: (ev) => {
1525
+ if (handleKey(ev)) ev.preventDefault();
1526
+ },
1527
+ // Layout, not scrolling, is what first tells a list how much of it is
1528
+ // worth building — and it is also where a page key gets its distance,
1529
+ // so this is measured whether or not the tree virtualizes.
1530
+ onViewport: (ev) => {
1531
+ win.sized(ev.width, ev.height);
1532
+ // The content just changed size, which is both the moment an owed
1533
+ // scroll can reach further than the clamp let it and the moment the
1534
+ // pane may have re-clamped its offset without saying so. It is not a
1535
+ // moment anything can be *settled* in: this runs from layout, a tick
1536
+ // before the pass that reads the rows it just drew back.
1537
+ reveal.retry(virtualizing);
1538
+ syncScroll();
1539
+ onViewport?.(ev);
1540
+ },
1541
+ onScroll: (ev) => {
1542
+ // A scroll this component did not ask for is the user taking over,
1543
+ // and an owed reveal must not yank the tree back out from under them
1544
+ // on the next layout.
1545
+ reveal.heard(ev.scrollY);
1546
+ win.scrolled(ev.scrollY);
1547
+ onScroll?.(ev);
1548
+ },
1083
1549
  },
1084
- },
1085
- body,
1550
+ body,
1551
+ ),
1552
+ scrollHint,
1086
1553
  );
1087
1554
  }