react-x11 2.45.0 → 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.45.0",
3
+ "version": "2.45.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -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
@@ -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
 
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/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
@@ -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;