react-x11 2.44.1 → 2.45.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.44.1",
3
+ "version": "2.45.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/cocoa/app.js CHANGED
@@ -1014,8 +1014,11 @@ export class CocoaApp {
1014
1014
  wnd?._shown?.(msg.seq);
1015
1015
  } else if (msg?.type === 'pane-rect') {
1016
1016
  pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
1017
+ if (typeof msg.live === 'boolean') pane.setLive(msg.live);
1017
1018
  this._flushPaneOutbox();
1018
1019
  this._afterInput();
1020
+ } else if (msg?.type === 'pane-live') {
1021
+ pane.setLive(msg.live === true);
1019
1022
  } else if (msg?.type === 'pane-menu-answer') {
1020
1023
  const answer = this._paneMenus?.get(msg.menu);
1021
1024
  this._paneMenus?.delete(msg.menu);
@@ -1687,6 +1690,9 @@ export class CocoaApp {
1687
1690
  const wnd = this._window(ev);
1688
1691
  if (!wnd || wnd.destroyed) return;
1689
1692
  wnd.liveResizing = ev.phase === 'begin';
1693
+ // and what lives in the window without being it hears: a `<Frame>`'s
1694
+ // pane, which has no window of its own to be resized live
1695
+ wnd.emit('liveresize', { live: wnd.liveResizing });
1690
1696
  }
1691
1697
 
1692
1698
  /**
@@ -11,7 +11,7 @@
11
11
  // What GlAreaNode needs from us is the child-"window" contract it already
12
12
  // speaks (src/glnodes.js): `createWindow({ parent, … })` answering an
13
13
  // object with `getContext('opengl', config)`, `setState(rect)`, `map()`,
14
- // `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
14
+ // `unmap()`, `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
15
15
  // window stacked above the parent's drawing; here it is a sublayer of the
16
16
  // window's root layer with a high zPosition — the same "GL sits above the
17
17
  // 2D" semantics, by the same mechanism the platform gives us. The layer
@@ -153,6 +153,9 @@ export class CocoaGLArea {
153
153
  return layer;
154
154
  });
155
155
  this.rect = null;
156
+ // Ours to keep, because every `setState` writes the layer's whole prop
157
+ // set: a geometry change on a hidden surface must not show it again.
158
+ this._hidden = false;
156
159
  this.setState({
157
160
  x: options.x ?? 0,
158
161
  y: options.y ?? 0,
@@ -178,7 +181,7 @@ export class CocoaGLArea {
178
181
  // mirroring the layer is the flip, applied where the compositor is
179
182
  // already transforming instead of in anyone's shader.
180
183
  transform: { scaleY: -1 },
181
- hidden: false,
184
+ hidden: this._hidden,
182
185
  });
183
186
  this._context?._resized(rect.width, rect.height);
184
187
  }
@@ -200,9 +203,22 @@ export class CocoaGLArea {
200
203
  }
201
204
 
202
205
  map() {
206
+ this._hidden = false;
203
207
  if (!this.destroyed) this._setLayerProps({ hidden: false });
204
208
  }
205
209
 
210
+ /**
211
+ * The other half of `map()`, which this surface went without: a layer is
212
+ * composited by the WindowServer rather than painted by the window, so a
213
+ * subtree going off the screen leaves it on screen over what replaced it.
214
+ * X11 and Wayland unmap the surface's window for this; here the layer's
215
+ * own `hidden` carries it.
216
+ */
217
+ unmap() {
218
+ this._hidden = true;
219
+ if (!this.destroyed) this._setLayerProps({ hidden: true });
220
+ }
221
+
206
222
  /**
207
223
  * Everything this layer is handed goes out with Core Animation's implicit
208
224
  * actions off. The layer is ours, not a presenter's, so no frame's
@@ -50,6 +50,8 @@ export class CocoaPaneWindow {
50
50
  // polls it through the pane's ref, so both spellings answer
51
51
  this.id = this.windowId;
52
52
  this.windowNumber = this.windowId;
53
+ // the host's word, which the pane has no AppKit to hear (`setLive`)
54
+ this.liveResizing = false;
53
55
  this.scale = app.scale ?? 2;
54
56
  // Device pixels, as every window's attributes are (`windowAttributes`
55
57
  // multiplies the props through once): the size the pane is born at,
@@ -112,6 +114,20 @@ export class CocoaPaneWindow {
112
114
  * a host that does not say. A move alone is a `resize` that `moved`, the
113
115
  * event a window's own move is, so what is anchored here follows it.
114
116
  */
117
+ /**
118
+ * Whether the window the pane is in is being resized live, as the host
119
+ * says (`pane-rect`, `pane-live`, src/frame/index.js): what a window's
120
+ * `liveResizing` is in a process of its own, where AppKit brackets the
121
+ * drag of its edge. Core defers a content floor's measurement while it
122
+ * is (src/nodes/window/size.js), and catches it up the frame after it
123
+ * ends; and it is announced as a window announces its own.
124
+ */
125
+ setLive(live) {
126
+ if (this.destroyed || this.liveResizing === live) return;
127
+ this.liveResizing = live;
128
+ this.emit('liveresize', { live });
129
+ }
130
+
115
131
  setPaneSize(width, height, scale, screen) {
116
132
  if (this.destroyed) return;
117
133
  if (scale) this.scale = scale;
@@ -278,6 +278,12 @@ export class CocoaWindow {
278
278
  let list = this._handlers.get(name);
279
279
  if (!list) this._handlers.set(name, (list = []));
280
280
  list.push(fn);
281
+ // and the way to stop listening, for a listener that goes before the
282
+ // window does (`<Frame>`'s, for `liveresize`)
283
+ return () => {
284
+ const at = list.indexOf(fn);
285
+ if (at >= 0) list.splice(at, 1);
286
+ };
281
287
  }
282
288
 
283
289
  emit(name, ev) {
@@ -488,18 +488,21 @@ export class ForeignNode extends Node {
488
488
  wnd.setBackgroundPixel(pixel);
489
489
  }
490
490
 
491
- setHidden(hidden) {
492
- super.setHidden(hidden);
491
+ /** The client's window is a window of the display system over this one,
492
+ * so it follows the whole subtree's visibility — see
493
+ * `GlAreaNode._syncSurfaceVisibility`, which answers the same call. */
494
+ _syncSurfaceVisibility() {
493
495
  this._syncMapped();
494
496
  }
495
497
 
496
- /** The container follows `hidden`; the client follows the container,
497
- * because an inferior of an unmapped window is not viewable however
498
- * mapped it is itself. */
498
+ /** The container follows whether this subtree is on screen — this node's
499
+ * own `hidden` flag, or anything above it that took the subtree away; the
500
+ * client follows the container, because an inferior of an unmapped window
501
+ * is not viewable however mapped it is itself. */
499
502
  _syncMapped() {
500
503
  const wnd = this.socket?.window;
501
504
  if (!wnd) return;
502
- if (this.hidden) wnd.unmap?.();
505
+ if (this._hiddenInTree()) wnd.unmap?.();
503
506
  else wnd.map?.();
504
507
  }
505
508
 
@@ -574,6 +574,20 @@ function PaneHostView({
574
574
  let acks = false;
575
575
  let inFlight = false;
576
576
  let waiting = null;
577
+ // And whether the window is being resized live — AppKit's drag of its
578
+ // edge, from its begin to its end (`liveResizing`) — which the pane
579
+ // has no window to hear. Core defers measuring a content floor for a
580
+ // drag's length and catches it up after it (src/nodes/window/size.js),
581
+ // and an element puts off what it would rather do once the drag ends:
582
+ // a pane measured its floors at every step, a document a pixel wide
583
+ // among them, and laid a document out whole at every rest of the drag.
584
+ // With every rect, and on its own where only that changed — the end
585
+ // of a drag comes after its last size.
586
+ const hostWindow = node.root?.window;
587
+ const liveNow = () => hostWindow?.liveResizing === true;
588
+ const offLive = hostWindow?.on?.('liveresize', (ev) =>
589
+ s.trySend?.({ type: 'pane-live', live: ev?.live === true }),
590
+ );
577
591
  const transmit = (rect) => {
578
592
  sent = rect;
579
593
  s.trySend?.({
@@ -581,6 +595,7 @@ function PaneHostView({
581
595
  width: rect.width,
582
596
  height: rect.height,
583
597
  scale: rect.scale,
598
+ live: liveNow(),
584
599
  ...(rect.screen && { screen: rect.screen }),
585
600
  });
586
601
  if (!acks) return;
@@ -685,6 +700,7 @@ function PaneHostView({
685
700
  waiting = null;
686
701
  offAnchor?.();
687
702
  offFocus?.();
703
+ if (typeof offLive === 'function') offLive();
688
704
  node.defaultCursor = undefined;
689
705
  node.root?.events?.refreshCursor();
690
706
  node.absolutize = origAbsolutize;
package/src/glnodes.js CHANGED
@@ -324,6 +324,9 @@ export class GlAreaNode extends Node {
324
324
  // same zPosition. The window's hit test reads the list in that order
325
325
  // (`EventManager._surfaceAt`).
326
326
  this._joinSurfaces();
327
+ // A surface created inside a subtree that is already hidden — a tab
328
+ // mounted behind the one on screen — must not come up over it.
329
+ this._syncSurfaceVisibility();
327
330
  this.gl = wnd.getContext('opengl', config);
328
331
  // A buffer freed by the display is a frame that can be drawn again — for
329
332
  // a frame that was waiting for one. The Cocoa surface and the CGL
@@ -725,8 +728,15 @@ export class GlAreaNode extends Node {
725
728
  this.requestFrame();
726
729
  }
727
730
 
728
- setHidden(hidden) {
729
- super.setHidden(hidden);
731
+ /**
732
+ * The surface is a window of the display system, so it goes when the
733
+ * subtree does — `display: 'none'` anywhere above it, or React's own
734
+ * `hidden` for `<Activity>`/`<Suspense>`, on this node or on an ancestor.
735
+ * `Node._syncSurfaces` calls this for every route; the panes the children
736
+ * are drawn on follow the same answer.
737
+ */
738
+ _syncSurfaceVisibility() {
739
+ const hidden = this._hiddenInTree();
730
740
  if (hidden) this.window?.unmap?.();
731
741
  else this.window?.map?.();
732
742
  this._overlay?.setHidden(hidden);
package/src/node.d.ts CHANGED
@@ -750,6 +750,20 @@ export declare class Node {
750
750
  * each scroll the pane blits, after layout.
751
751
  */
752
752
  viewportFixedRects(): Rect[] | null;
753
+ /**
754
+ * What this element draws under the viewport of the scroll pane it is in
755
+ * moved by (dx, dy) device pixels, and what the viewport shows is to stay
756
+ * where it is on screen: the pane's offsets move by as much, in the
757
+ * layout pass running now, after it measures what it holds and before it
758
+ * places it (CSS Scroll Anchoring 1) — a document laid out again at
759
+ * another width, whose text above the fold broke into more lines or
760
+ * fewer. Asked of the nearest scroll pane; shifts in one pass add up.
761
+ * Ask from the layout pass (`measureContent`); asked outside one, the
762
+ * shift waits for the next and asks for it. A `scrollTo` made since the
763
+ * last pass drops it, a `scrollIntoView` lands after it, and the result
764
+ * is clamped as any scroll. Returns whether a pane took it.
765
+ */
766
+ anchorScrollBy(dx: number, dy: number): boolean;
753
767
  /**
754
768
  * How far this element's own drawing reaches past its box, in device
755
769
  * pixels on every side — 0, the default, for an element that stays inside
@@ -137,6 +137,36 @@ export class NodeInvalidate {
137
137
  return null;
138
138
  }
139
139
 
140
+ /**
141
+ * What this element draws under the viewport of the scroll pane it is
142
+ * in moved by (dx, dy) device pixels, and what the viewport shows is to
143
+ * stay where it is on screen: the pane's offsets move by as much, in the
144
+ * layout pass running now, after it measures what it holds and before it
145
+ * places it (CSS Scroll Anchoring 1). A document laid out again at
146
+ * another width is the case: its text above the fold breaks into more
147
+ * lines or fewer, and the paragraph at the top of the viewport moves
148
+ * down or up the document by the difference — which, unanswered, scrolls
149
+ * the reader a page away from where they were.
150
+ *
151
+ * Asked of the nearest scroll pane, the one whose viewport the element
152
+ * is seen through; shifts asked of it in one pass add up. Ask from the
153
+ * layout pass — `measureContent`, or what it calls — where the pane
154
+ * places its content after; asked outside one, the shift waits for the
155
+ * next pass, and asks for it. A `scrollTo` the application made since
156
+ * the last pass drops it, since the shift was measured against the
157
+ * offsets before that scroll; a `scrollIntoView` lands after it; and the
158
+ * pane clamps the result to what it holds, as any scroll. Returns
159
+ * whether a pane took it: false where nothing scrolls the element.
160
+ */
161
+ anchorScrollBy(dx, dy) {
162
+ for (let n = this.parent; n; n = n.parent) {
163
+ if (typeof n._anchorScrollBy === 'function' && n.isScroller()) {
164
+ return n._anchorScrollBy(dx, dy);
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
140
170
  /**
141
171
  * How far this element's own drawing reaches past its box — device
142
172
  * pixels, on every side — or 0, the default, for one that stays inside
package/src/nodes/node.js CHANGED
@@ -860,12 +860,31 @@ export class Node {
860
860
  // window rather than for this subtree — the set is the handful of nodes
861
861
  // that declare an `animation`, and each one answers for itself.
862
862
  if (this.root?._loopNodes?.size) this.root._refreshLoops();
863
+ // Same funnel, same reason, one layer down: a surface — a `<glarea>`'s GL
864
+ // child, an embedded client's window — is a real window of the display
865
+ // system stacked above everything this window paints, not pixels in its
866
+ // 2d context. Hiding a subtree takes away its layout and its paint, and
867
+ // reaches a surface inside it through neither: it would stay on screen,
868
+ // over whatever is shown in its place, which is what a tab that keeps its
869
+ // screens mounted does every time it switches.
870
+ this._syncSurfaces();
863
871
  const events = this._focusManager();
864
872
  if (!events) return;
865
873
  if (visible) events.subtreeRevealed(this);
866
874
  else events.subtreeHidden(this);
867
875
  }
868
876
 
877
+ /**
878
+ * Ask every surface in this subtree — this node included — whether it is
879
+ * still on screen, and map or unmap it to match. Walked rather than kept
880
+ * in a registry because the subtree whose visibility just changed is
881
+ * exactly the scope, and all but two elements answer nothing.
882
+ */
883
+ _syncSurfaces() {
884
+ this._syncSurfaceVisibility?.();
885
+ for (const child of this.children) child._syncSurfaces?.();
886
+ }
887
+
869
888
  /**
870
889
  * Focus this node, as clicking it would: the owning window's focus moves
871
890
  * here, `onBlur` fires on whatever had it, `onFocus` here. Also pulls the
@@ -330,6 +330,10 @@ export const Scrollable = (Base) =>
330
330
  // the viewport grew, under it. A browser fires `scroll` for each, so
331
331
  // `onScroll` hears of them too, once the pass is over.
332
332
  const from = { x: this.scrollX, y: this.scrollY };
333
+ // What the content asked to keep in place lands before them all
334
+ // (`anchorScrollBy`): it moved under the viewport in this pass, and
335
+ // a scroll anyone asks for is from where it shows what it did
336
+ this._applyAnchorShift();
333
337
  // A scrollTo held for this pass lands first, so a node asked into view
334
338
  // in the same frame is brought in from where that scroll put the pane:
335
339
  // the order the two have on a laid-out pane, where scrollTo applies at
@@ -607,6 +611,10 @@ export const Scrollable = (Base) =>
607
611
  }
608
612
  this.scrollX = next.x;
609
613
  this.scrollY = next.y;
614
+ // a scroll asked for since the last pass is where the pane goes: a
615
+ // shift the content asks for in the next was measured against the
616
+ // offsets before it (`_applyAnchorShift`)
617
+ this._scrolledSincePass = true;
610
618
  if (moved) this.props.onScroll?.(this._scrollEvent());
611
619
  // A scroll reflows this viewport's contents and nothing else, and the
612
620
  // viewport clips them, so the damage is this node's own rect. It is a
@@ -707,6 +715,47 @@ export const Scrollable = (Base) =>
707
715
  return true;
708
716
  }
709
717
 
718
+ /**
719
+ * The pane's half of `Node.anchorScrollBy`: a shift of the offsets the
720
+ * content asked for, added up until the pass applies it
721
+ * (`_applyAnchorShift`). The pass measures the content again, since
722
+ * what moved under the viewport moved by being laid out; one asked
723
+ * outside a pass asks for one.
724
+ */
725
+ _anchorScrollBy(dx, dy) {
726
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return false;
727
+ if (dx === 0 && dy === 0) return true;
728
+ const shift = (this._anchorShift ??= { x: 0, y: 0 });
729
+ shift.x += dx;
730
+ shift.y += dy;
731
+ this._scrollMeasureDirty = true;
732
+ const root = this.root;
733
+ if (root && !root._inFlush) root.invalidate(true, this, 'scroll');
734
+ return true;
735
+ }
736
+
737
+ /**
738
+ * Apply the shift the content asked for (`_anchorScrollBy`), before
739
+ * anything else the pass moves the offsets by: on each axis it scrolls,
740
+ * the clamp after takes it into the extent just measured. Its
741
+ * `onScroll` and its claim of the viewport are the pass's, as for a
742
+ * held `scrollTo`. A scroll the application asked for since the last
743
+ * pass drops it: the shift was measured against the offsets before
744
+ * that scroll, which is where the application wants the pane now.
745
+ */
746
+ _applyAnchorShift() {
747
+ const shift = this._anchorShift;
748
+ const scrolled = this._scrolledSincePass === true;
749
+ this._scrolledSincePass = false;
750
+ if (!shift) return;
751
+ this._anchorShift = null;
752
+ if (scrolled) return;
753
+ // `scrollX` is a distance from the start in either direction, and
754
+ // content that moved towards the end is followed the same way
755
+ this.scrollX += this.direction === 'rtl' ? -shift.x : shift.x;
756
+ this.scrollY += shift.y;
757
+ }
758
+
710
759
  /**
711
760
  * Answer a held request (`_holdScrollTo`) against the extent this pass
712
761
  * has just measured: each held axis's base, clamped, then each step
@@ -533,10 +533,12 @@ export function setMeasuringShrink(node, axis, out) {
533
533
  * It is still laid out, though, and a leaf's answer could reach something
534
534
  * that is read through its size. So it is marked only where it cannot:
535
535
  *
536
- * - every box from the root down to it is as wide as something other than
537
- * its content — the root offered no room, a width it names, a column
538
- * stretching it — so the width the leaf answers is one yoga replaces, and
539
- * nothing above it is sized from it;
536
+ * - every box from `node` down to it is as wide as something other than
537
+ * its content — `node` the width the pass gives it (no room, for the
538
+ * window; its column's, for a spine root; its own, for a box that sizes
539
+ * itself), and below it a width it names or a column stretching it — so
540
+ * the width the leaf answers is one yoga replaces, and nothing above it
541
+ * is sized from it;
540
542
  * - no box is handed a share of space the leaf's height changed, which is
541
543
  * how a height could become somebody's width — an aspect ratio, a column
542
544
  * that wraps, an image sized to the height it is given. Every column from
@@ -656,15 +658,28 @@ function stretchedAlong(node, axis) {
656
658
  * names are numbers — a height, a basis (a height, down a column), a floor or
657
659
  * a ceiling; and `'unknown'` for a share of something (a percentage), which
658
660
  * a pass with no height on offer resolves as it likes.
661
+ *
662
+ * A floor of nothing bounds nothing: a box as tall as its content is at
663
+ * least that tall already, so its height is its content's. It is how a box
664
+ * says it may shrink, and a browser's page has one on every box from the
665
+ * window down to its scroller, so that a page taller than the window
666
+ * scrolls. Read as a height of the box's own, it stopped the walk at the
667
+ * first of them, where the scroller gives way: the whole document in the
668
+ * scroller was set a word to a line each time the tree was measured, 100
669
+ * to 250 ms of a frame for a long Wikipedia article, and nothing read any
670
+ * of it. And it took the box pinned to both edges of such a column for one
671
+ * whose height nothing in flow decides, which it is.
659
672
  */
660
673
  function heightOf(yoga) {
674
+ const floor = yoga.getMinHeight();
661
675
  let own = false;
662
676
  for (const value of [
663
677
  yoga.getHeight(),
664
678
  yoga.getFlexBasis(),
665
- yoga.getMinHeight(),
679
+ floor.value === 0 ? null : floor,
666
680
  yoga.getMaxHeight(),
667
681
  ]) {
682
+ if (value === null) continue;
668
683
  if (value.unit === Yoga.UNIT_PERCENT) return 'unknown';
669
684
  if (value.unit === Yoga.UNIT_POINT) own = true;
670
685
  }
@@ -354,12 +354,28 @@ export class WindowSize {
354
354
  if (axis === 'width') {
355
355
  const shrunk = [];
356
356
  setMeasuringShrink(root, axis, shrunk);
357
+ // Nothing reads a root's own width — only heights travel up a spine
358
+ // (spine.js) — so what it holds is read only where a floor is written
359
+ // from it, as down the window's columns, and a leaf that is the root
360
+ // is laid out at the width its column offers, which is no answer of
361
+ // its own: a document in a scroller is a root when it changes.
362
+ const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
363
+ const unread = [];
364
+ if (!read && root._measureFn) {
365
+ root._widthUnread = true;
366
+ unread.push(root);
367
+ } else if (!read) {
368
+ markUnreadLeaves(root, unread);
369
+ }
357
370
  this._scopedFloorPasses += 1;
358
- yoga.calculateLayout(widths.widthPass, undefined, dir);
359
- const span = contentSpan(root, axis, null, this);
360
- root._floorW = declaresOwnMinimum(root, axis)
361
- ? yoga.getComputedWidth()
362
- : span;
371
+ try {
372
+ yoga.calculateLayout(widths.widthPass, undefined, dir);
373
+ } finally {
374
+ for (const leaf of unread) leaf._widthUnread = false;
375
+ }
376
+ const span = contentSpan(root, axis, null, this, read);
377
+ const own = declaresOwnMinimum(root, axis);
378
+ if (own || read) root._floorW = own ? yoga.getComputedWidth() : span;
363
379
  restoreShrink(shrunk);
364
380
  return;
365
381
  }
@@ -443,9 +459,18 @@ export class WindowSize {
443
459
  if (axis === 'width') {
444
460
  const shrunk = [];
445
461
  setMeasuringShrink(boundary, axis, shrunk);
462
+ // its own extent is its style's, so what it holds is read only where
463
+ // a floor is written from it, as down the window's columns
464
+ const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
465
+ const unread = [];
466
+ if (!read) markUnreadLeaves(boundary, unread);
446
467
  this._scopedFloorPasses += 1;
447
- yoga.calculateLayout(undefined, undefined, dir);
448
- contentSpan(boundary, axis, null, this);
468
+ try {
469
+ yoga.calculateLayout(undefined, undefined, dir);
470
+ } finally {
471
+ for (const leaf of unread) leaf._widthUnread = false;
472
+ }
473
+ contentSpan(boundary, axis, null, this, read);
449
474
  restoreShrink(shrunk);
450
475
  return;
451
476
  }
@@ -559,32 +559,50 @@ function restoreFrameClock() {
559
559
  }
560
560
 
561
561
  /**
562
- * Retry `fn` until it stops throwing, flushing between attempts.
562
+ * Retry `fn` until it stops throwing, flushing between attempts, and resolve
563
+ * with what it returned once that is on screen.
563
564
  *
564
565
  * The tool for anything whose arrival is not a single deterministic step:
565
566
  * an async rich-content reflow, an image decode, a timer, or a repaint whose
566
- * cause travelled through the X connection and back. Each attempt is
567
- * preceded by an `act()`, so waiting also *advances* — this is not a sleep
568
- * with a condition on it.
567
+ * cause travelled through the X connection and back. Each retry is preceded
568
+ * by an `act()`, so waiting also *advances* — this is not a sleep with a
569
+ * condition on it.
569
570
  *
570
571
  * ```js
571
572
  * await waitFor(async () => {
572
573
  * assert.notDeepStrictEqual(await pixelAt(ctx, x, y), before);
573
574
  * });
575
+ * const row = await waitFor(() => screen.getByText('notes.txt'));
576
+ * await userEvent.click(row);
574
577
  * ```
578
+ *
579
+ * **The attempt that passes is followed by an `act()` too.** What it found
580
+ * can have arrived in the sleep before it — an fs read, a timer — and that
581
+ * commits outside act, on React's own scheduler, while the frame that lays
582
+ * it out runs on the window's clock, which need not have ticked since.
583
+ * Without it, a node it returned could have no rect yet, and the click after
584
+ * it would throw `has no laid-out rect yet` (#904). It follows whichever
585
+ * attempt passed, the first included, since the commit can land before
586
+ * `waitFor` is called. And it has to come after the attempt: an `act()`
587
+ * before each one leaves the same gap in its own closing round trip, after
588
+ * the frame it ran.
575
589
  */
576
590
  export async function waitFor(fn, { timeout = 2000, interval = 10 } = {}) {
577
591
  const deadline = Date.now() + timeout;
578
592
  let last;
579
593
  for (;;) {
594
+ let found;
580
595
  try {
581
- return await fn();
596
+ found = await fn();
582
597
  } catch (err) {
583
598
  last = err;
599
+ if (Date.now() >= deadline) throw last;
600
+ await act();
601
+ await new Promise((resolve) => setTimeout(resolve, interval));
602
+ continue;
584
603
  }
585
- if (Date.now() >= deadline) throw last;
586
604
  await act();
587
- await new Promise((resolve) => setTimeout(resolve, interval));
605
+ return found;
588
606
  }
589
607
  }
590
608
 
@@ -174,6 +174,13 @@ export interface A11ySpy {
174
174
  uninstall(): void;
175
175
  }
176
176
 
177
+ /**
178
+ * Testing Library's four families over the retained node tree: `getBy*`
179
+ * (exactly one, or throw), `queryBy*` (one or null), `getAllBy*` (one or
180
+ * more), and `findBy*` — `getBy*` under {@link waitFor}, with its options and
181
+ * defaults, so it retries until the node appears and resolves once a frame
182
+ * has laid it out.
183
+ */
177
184
  export interface Queries {
178
185
  getByText(text: string | RegExp, options?: TextMatchOptions): DrawnNode;
179
186
  getAllByText(text: string | RegExp, options?: TextMatchOptions): DrawnNode[];
@@ -339,7 +346,16 @@ export function setAppearance(values: PinnedAppearance): Promise<void>;
339
346
  /** Drain in-flight requests on a connection. */
340
347
  export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
341
348
 
342
- /** Retry until it stops throwing, `act`-ing between attempts. */
349
+ /**
350
+ * Retry until it stops throwing, `act`-ing between attempts — and once more
351
+ * after the attempt that passed, so what it resolves with is on screen: a
352
+ * node it found has been laid out, and a click on it lands.
353
+ *
354
+ * ```ts
355
+ * const row = await waitFor(() => screen.getByText('notes.txt'));
356
+ * await userEvent.click(row);
357
+ * ```
358
+ */
343
359
  export function waitFor<T>(
344
360
  fn: () => T | Promise<T>,
345
361
  options?: WaitOptions,
@@ -12,13 +12,13 @@
12
12
  // getBy* exactly one, or throw
13
13
  // queryBy* one or null, never throws — the way to assert absence
14
14
  // getAllBy* one or more, or throw
15
- // findBy* getBy*, retried until it appears or the timeout expires
15
+ // findBy* getBy* under waitFor: retried until it appears or the timeout
16
+ // expires, and laid out by the time it resolves
16
17
 
17
18
  import { queryAllByComponent, componentInventory } from './components.js';
19
+ import { waitFor } from './harness.js';
18
20
  import { roleNameOf } from '../a11y.js';
19
21
 
20
- const DEFAULT_TIMEOUT = 1000;
21
-
22
22
  /** Depth-first, in paint order, including `<popup>` subtrees. */
23
23
  function walk(node, visit) {
24
24
  if (!node || node.destroyed) return;
@@ -97,20 +97,6 @@ function one(root, found, what, printTree = inventory) {
97
97
  );
98
98
  }
99
99
 
100
- async function retry(fn, { timeout = DEFAULT_TIMEOUT, interval = 20 } = {}) {
101
- const deadline = Date.now() + timeout;
102
- let last;
103
- for (;;) {
104
- try {
105
- return fn();
106
- } catch (err) {
107
- last = err;
108
- if (Date.now() >= deadline) throw last;
109
- await new Promise((r) => setTimeout(r, interval));
110
- }
111
- }
112
- }
113
-
114
100
  /**
115
101
  * The queries, bound to a subtree. `within(node)` is this with a different
116
102
  * root; `screen` is this bound to whatever `renderX11` mounted last.
@@ -199,9 +185,12 @@ export function within(root) {
199
185
  if (found.length > 1) return one(root, found, label(args[0]));
200
186
  return found[0] ?? null;
201
187
  };
188
+ // Through waitFor rather than a loop of its own: what this resolves with
189
+ // has to be laid out, or a click on it throws, and waitFor is what runs
190
+ // the frame before resolving
202
191
  q[`find${suffix}`] = (...args) => {
203
192
  const options = args.length > 1 ? args[args.length - 1] : undefined;
204
- return retry(() => q[`get${suffix}`](...args), options);
193
+ return waitFor(() => q[`get${suffix}`](...args), options);
205
194
  };
206
195
  }
207
196
  return q;