react-x11 2.15.2 → 2.16.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.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
@@ -59,6 +59,17 @@ function replayHeld({ base, steps }, max) {
59
59
  // walked per node per hit test.
60
60
  const EMPTY_SCROLLBARS = Object.freeze([]);
61
61
 
62
+ /** Do two answers from `_scrollbar` paint the same thumb? Neither painting
63
+ * one counts. */
64
+ const sameThumb = (a, b) =>
65
+ a === b ||
66
+ (a !== undefined &&
67
+ b !== undefined &&
68
+ a.x === b.x &&
69
+ a.y === b.y &&
70
+ a.width === b.width &&
71
+ a.height === b.height);
72
+
62
73
  /**
63
74
  * Scrolling, as a style rather than as a species of node.
64
75
  *
@@ -177,6 +188,9 @@ export const Scrollable = (Base) =>
177
188
  this._childOrigin != null &&
178
189
  !this._scrollMeasureDirty &&
179
190
  !this.yoga.hasNewLayout();
191
+ // The thumbs as they stand, for a bounded frame to hold the measure
192
+ // below against (`_claimThumbs`)
193
+ const thumbsWere = !clean && layoutDiff.sink ? this._scrollbars() : null;
180
194
  if (!clean) {
181
195
  const size = this.measureScrollContent();
182
196
  if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
@@ -208,8 +222,13 @@ export const Scrollable = (Base) =>
208
222
  // once and scrollIntoView waits for the pass.
209
223
  this._resolveScrollTo();
210
224
  this._resolveScrollIntoView();
225
+ const askedX = this.scrollX;
226
+ const askedY = this.scrollY;
211
227
  this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
212
228
  this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
229
+ // Of those three moves the clamp's is the one no call claimed when it
230
+ // was asked for — the diff below claims it
231
+ const clamped = this.scrollX !== askedX || this.scrollY !== askedY;
213
232
  this._reportViewport();
214
233
  this._reportScrollTo(from);
215
234
  // `scrollX` is how far the content has moved **from its start**, which
@@ -271,14 +290,22 @@ export const Scrollable = (Base) =>
271
290
  }
272
291
  };
273
292
  layoutDiff.shift = { x: ox - wasOrigin.x, y: oy - wasOrigin.y };
274
- } else if (shifted) {
275
- layoutDiff.sink = null;
276
293
  } else {
277
294
  const vp = insetRect(this.abs, -DAMAGE_SLOP);
278
- layoutDiff.sink = (rect) => {
279
- const clipped = intersectRects(rect, vp);
280
- if (clipped) outer(clipped);
281
- };
295
+ if (shifted) {
296
+ layoutDiff.sink = null;
297
+ // …which holds for a scroll's shift and not for the clamp's: the
298
+ // content shrank, or the viewport grew, under the offset — a row
299
+ // collapsing at the end of a list scrolled to its end — and
300
+ // nothing claimed the viewport every child just moved in.
301
+ if (clamped) outer(vp);
302
+ } else {
303
+ layoutDiff.sink = (rect) => {
304
+ const clipped = intersectRects(rect, vp);
305
+ if (clipped) outer(clipped);
306
+ };
307
+ }
308
+ this._claimThumbs(thumbsWere, outer);
282
309
  }
283
310
  }
284
311
  try {
@@ -818,6 +845,26 @@ export const Scrollable = (Base) =>
818
845
  return [this._scrollbar('y'), this._scrollbar('x')].filter(Boolean);
819
846
  }
820
847
 
848
+ /**
849
+ * A bounded layout pass's claim for the thumbs it changed, where each
850
+ * was and where it is. The pane paints them, not a node with a rect of
851
+ * its own, so content growing or shrinking under a pane that stays put
852
+ * slides a thumb or resizes it and the layout diff sees nothing move.
853
+ * `was` is `_scrollbars()` from before the pass measured the content.
854
+ */
855
+ _claimThumbs(was, sink) {
856
+ const now = this._scrollbars();
857
+ for (const axis of ['y', 'x']) {
858
+ const before = was.find((bar) => bar.axis === axis);
859
+ const after = now.find((bar) => bar.axis === axis);
860
+ if (sameThumb(before, after)) continue;
861
+ // a pixel of slop for the rounded corners' antialiasing, as the
862
+ // blit's own thumb repair takes
863
+ if (before) sink(insetRect(before, -1));
864
+ if (after) sink(insetRect(after, -1));
865
+ }
866
+ }
867
+
821
868
  /**
822
869
  * The bar belongs to the scroller, not to the content under it — the same
823
870
  * rule a browser applies. Without this a press on the thumb would be
package/src/nodes/text.js CHANGED
@@ -338,6 +338,8 @@ export class TextNode extends Node {
338
338
  variations: style.variations,
339
339
  textRendering: style.textRendering,
340
340
  color: style.color,
341
+ letterSpacing: style.letterSpacing,
342
+ features: style.features,
341
343
  });
342
344
  } else if (child.kind === 'text') {
343
345
  child.collectSpans(out);
@@ -137,7 +137,7 @@ export class TextAreaNode extends TextInputNode {
137
137
  width === undefined || direction !== 'rtl'
138
138
  ? width
139
139
  : Math.max(0, width - CARET_RESERVE * this.scale);
140
- const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}`;
140
+ const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
141
141
  if (this._valueLayoutKey !== key) {
142
142
  this._valueLayoutKey = key;
143
143
  this._valueLayoutCache = fonts.layout([{ text: shown, ...s, color }], s, {
@@ -302,7 +302,7 @@ export class TextInputNode extends Node {
302
302
  const text = this._displayValue();
303
303
  const s = this.resolvedTextStyle();
304
304
  const direction = this.direction;
305
- const key = `${text}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}`;
305
+ const key = `${text}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
306
306
  if (this._valueLayoutKey !== key) {
307
307
  this._valueLayoutKey = key;
308
308
  this._valueLayoutCache = fonts.layout(text, s, { direction });
@@ -1,7 +1,7 @@
1
1
  // A window anchored to a rect in another (#255, #280): where it goes, and
2
2
  // following the anchor as it moves.
3
3
 
4
- import { anchorOffscreen, anchorRect } from '../../anchor.js';
4
+ import { anchorOffscreen, anchorRect, anchorScreenRect } from '../../anchor.js';
5
5
 
6
6
  /** Anchoring, installed onto `WindowNode.prototype` by window.js. */
7
7
  export class WindowAnchoring {
@@ -32,18 +32,33 @@ export class WindowAnchoring {
32
32
  * anchor to yet — a ref whose node has not been laid out. */
33
33
  _anchorPlacement(size) {
34
34
  const anchor = this.props.anchor;
35
- const node = this._anchorTarget(anchor?.to);
36
- if (!node) return null;
35
+ if (!anchor) return null;
37
36
  // `anchorRect` is public API and speaks logical pixels on both sides;
38
37
  // this caller's `size` came from `_measure` (device) and its result is
39
38
  // headed for CreateWindow (device), so both convert here.
40
39
  const s = this.scale;
41
- const rect = anchorRect(node, {
42
- ...anchor,
43
- alignTo: this._anchorTarget(anchor.alignTo) ?? undefined,
44
- width: size.width / s,
45
- height: size.height / s,
46
- });
40
+ let rect;
41
+ if (anchor.rect) {
42
+ // A rect on the screen with no node behind it — the tray item a click
43
+ // reported. The popup's own scale and direction stand in for the
44
+ // node's.
45
+ rect = anchorScreenRect(this.app, anchor.rect, {
46
+ ...anchor,
47
+ scale: s,
48
+ direction: anchor.direction ?? this.direction,
49
+ width: size.width / s,
50
+ height: size.height / s,
51
+ });
52
+ } else {
53
+ const node = this._anchorTarget(anchor.to);
54
+ if (!node) return null;
55
+ rect = anchorRect(node, {
56
+ ...anchor,
57
+ alignTo: this._anchorTarget(anchor.alignTo) ?? undefined,
58
+ width: size.width / s,
59
+ height: size.height / s,
60
+ });
61
+ }
47
62
  if (!rect || s === 1) return rect;
48
63
  return {
49
64
  ...rect,
@@ -76,13 +91,7 @@ export class WindowAnchoring {
76
91
  */
77
92
  _followAnchor(size = this._requestedSize) {
78
93
  if (this.destroyed || !this.window || !this.props.anchor) return;
79
- const node = this._anchorTarget(this.props.anchor.to);
80
- // A ref that has not attached yet counts as gone, and for the same
81
- // reason: there is nowhere for the popup to be. Refs attach in the
82
- // commit phase a popup realizes in, so one written *above* its own
83
- // trigger in the JSX gets a frame of this — and waiting it out is
84
- // better than a frame in the corner of the screen.
85
- const lost = !node || anchorOffscreen(node, this.props.anchor.at);
94
+ const lost = this._anchorGone();
86
95
  if (lost !== Boolean(this._anchorLost)) {
87
96
  this._anchorLost = lost;
88
97
  // The grab goes with it and comes back with it. X releases a pointer
@@ -93,6 +102,7 @@ export class WindowAnchoring {
93
102
  // keeps it beside the map on every route back to the screen.
94
103
  if (lost) {
95
104
  if (this.props.grab) this.window.ungrabPointer?.();
105
+ if (this.props.grabKeyboard) this.window.ungrabKeyboard?.();
96
106
  this.window.unmap?.();
97
107
  } else {
98
108
  this._mapNow();
@@ -110,14 +120,31 @@ export class WindowAnchoring {
110
120
  }
111
121
  }
112
122
 
123
+ /**
124
+ * Whether there is nothing for this window to point at: the anchor's node
125
+ * has scrolled out of view, or is a ref that has not attached yet — which
126
+ * counts as gone for the same reason, since there is nowhere for the popup
127
+ * to be. Refs attach in the commit phase a popup realizes in, so one
128
+ * written *above* its own trigger in the JSX gets a frame of this, and
129
+ * waiting it out is better than a frame in the corner of the screen. A
130
+ * rect on the screen is never gone.
131
+ */
132
+ _anchorGone() {
133
+ const anchor = this.props.anchor;
134
+ if (anchor.rect) return false;
135
+ const node = this._anchorTarget(anchor.to);
136
+ return !node || anchorOffscreen(node, anchor.at);
137
+ }
138
+
113
139
  /**
114
140
  * Subscribe to whatever can move the anchor. On the **owner's** window,
115
141
  * not on this one: what moves is the trigger, and this window's own
116
- * layout passes say nothing about where it sits on screen.
142
+ * layout passes say nothing about where it sits on screen. A rect on the
143
+ * screen moves only when the app hands a new one, which is a commit.
117
144
  */
118
145
  _watchAnchor() {
119
146
  this._unwatchAnchor();
120
- if (!this.props.anchor) return;
147
+ if (!this.props.anchor || this.props.anchor.rect) return;
121
148
  // The anchor's own window where the ref has attached, and the window
122
149
  // this popup was *written into* otherwise — which is the same one in
123
150
  // every case that matters, and is what makes an unattached ref a
@@ -75,8 +75,8 @@ export class WindowFlush {
75
75
 
76
76
  /**
77
77
  * The backend's half of a frame's cost, where it has one: the Cocoa
78
- * present — the swapchain flip and its catch-up copy runs after the
79
- * flush returns, on the same thread, and is part of what the frame cost
78
+ * present — the swapchain flip runs after the flush returns, on the
79
+ * same thread, and is part of what the frame cost
80
80
  * (src/cocoa/window.js reports it). An ntk window's present is one
81
81
  * request, and reports nothing.
82
82
  */
@@ -335,11 +335,12 @@ export class WindowFlush {
335
335
  // after every region: an entry drawn in one damage rect must not be
336
336
  // evicted before the next rect of the same frame asks for it
337
337
  this._paintCache?.endFrame();
338
- // The swapchain seam: a backend presenting from double buffers has to
338
+ // The swapchain seam: a backend presenting from a swapchain has to
339
339
  // know exactly which pixels each flush touched — several flushes can
340
340
  // land between two presents, so reading only the last frame's rects
341
- // would leave the flipped-in back buffer stale where an earlier flush
342
- // painted. Feature-detected like presentFrame; null means everything.
341
+ // would leave the next buffer it draws into stale where an earlier
342
+ // flush painted. Feature-detected like presentFrame; null means
343
+ // everything.
343
344
  this.window.noteFrameDamage?.(damage ?? null);
344
345
  if (frameHook) {
345
346
  frameHook({
@@ -30,15 +30,25 @@ export class PopupNode extends WindowNode {
30
30
  * from realize looked equivalent until `hidden` existed, and would have
31
31
  * left a revealed menu holding no grab: open forever behind the first
32
32
  * outside click, with nothing saying why.
33
+ *
34
+ * `grabKeyboard` is the keyboard's half, taken and dropped with the map
35
+ * the same way: keys come to this popup while it is up, whatever holds
36
+ * the focus — what a popover a tray click opened needs, since a menu-bar
37
+ * app has no window of its own for the keys to reach. On Cocoa it is a
38
+ * property of the window rather than a grab, and was decided when the
39
+ * window was made (src/cocoa/window.js); on Wayland a grabbing popup has
40
+ * the keyboard already.
33
41
  */
34
42
  _mapNow() {
35
43
  if (!super._mapNow()) return false;
36
44
  if (this.props.grab) this.window.grabPointer?.({}, () => {});
45
+ if (this.props.grabKeyboard) this.window.grabKeyboard?.({}, () => {});
37
46
  return true;
38
47
  }
39
48
 
40
49
  destroySubtree() {
41
50
  if (this.props.grab) this.window?.ungrabPointer?.();
51
+ if (this.props.grabKeyboard) this.window?.ungrabKeyboard?.();
42
52
  super.destroySubtree();
43
53
  }
44
54
 
@@ -398,6 +398,18 @@ export class WindowSize {
398
398
  return { width: props.width, height: props.height, hints };
399
399
  }
400
400
 
401
+ // **The floors on hand are the last frame's content.** A node whose
402
+ // content shrank still carries the minimum the old content needed — the
403
+ // automatic minimum size this window wrote into yoga — and a natural-size
404
+ // pass would read it back as content the tree cannot give up, so an
405
+ // `'auto'` window could only ever grow (#586). The stale ones come off
406
+ // first, which is exactly what `_applyContentFloors` does ahead of its
407
+ // own measurement: a node whose extent is stale gets the minimum its
408
+ // style asks for, and a node whose extent still stands keeps the floor it
409
+ // earned. The pass after this one measures and writes them again.
410
+ this._writeFloors('width');
411
+ this._writeFloors('height');
412
+
401
413
  const dir = this._rootDirection;
402
414
  const measure = () => {
403
415
  this._sweepLayoutHosts();
@@ -525,9 +537,14 @@ export class WindowSize {
525
537
  );
526
538
  if (!tracking && !bounded) return;
527
539
  const asked = this._requestedSize;
528
- const next = this._measure();
529
- this._sendSizeHints(props, next.hints);
540
+ const measured = this._measure();
541
+ this._sendSizeHints(props, measured.hints);
530
542
  if (!tracking) return;
543
+ // The size the backend will really give, compared against the last one
544
+ // for the same reason it is recorded: two natural sizes a third of a
545
+ // point apart are one window size, and asking again every frame for a
546
+ // size that cannot change is a configure per frame.
547
+ const next = this._snapSize(measured);
531
548
  if (asked && next.width === asked.width && next.height === asked.height) {
532
549
  return;
533
550
  }
@@ -553,6 +570,27 @@ export class WindowSize {
553
570
  this._followAnchor({ width: next.width, height: next.height });
554
571
  }
555
572
 
573
+ /**
574
+ * The size the backend will really take for one this window asks for.
575
+ *
576
+ * A window is not always free to be exactly the size its content wants:
577
+ * Cocoa puts one on the **point** grid, so a natural height of 201 device
578
+ * pixels at scale 2 is a window 202 tall. Every record of
579
+ * `_requestedSize` goes through here, because that record is what the
580
+ * resize echo is compared against — and an echo that does not match it
581
+ * reads as the user or the window manager taking the size over, which
582
+ * ends an `'auto'` window's tracking for good (#586). A backend that
583
+ * takes any size, X11's, answers with the size it was handed.
584
+ */
585
+ _snapSize(size) {
586
+ return (
587
+ this.window?.snapSize?.(size.width, size.height) ?? {
588
+ width: size.width,
589
+ height: size.height,
590
+ }
591
+ );
592
+ }
593
+
556
594
  /** One layout pass at the window's size, with the content floors it
557
595
  * needs: fresh ones when something changed them, the ones in hand during
558
596
  * a live resize, none when nothing moved them. */
@@ -11,7 +11,6 @@ import { forgetTopLevel, hasDropProps } from '../../dnd.js';
11
11
  import { clearPendingFrame } from '../../frames.js';
12
12
  import { FramePacer } from '../../pacing.js';
13
13
  import { endWindowState } from '../../windowstate.js';
14
- import { anchorOffscreen } from '../../anchor.js';
15
14
  import { topLevelWindows } from '../../windowid.js';
16
15
  import { WindowAnimation } from '../animation.js';
17
16
  import { WindowCascade } from '../cascade.js';
@@ -153,6 +152,12 @@ export class WindowNode extends Scrollable(Node) {
153
152
  // one flag everything reads (`_mapNow`, painting, a11y, anchoring).
154
153
  this._reactHidden = false;
155
154
  this.hidden = Boolean(props.hidden);
155
+ // A `<ThemeProvider>` this window takes its palette from other than its
156
+ // parent — one written above it at the root, or one directly inside the
157
+ // window it is nested in, which handed it on — and a third writer of
158
+ // `hidden`, since React hides the provider's node rather than the window
159
+ // under it (nodes/scope.js). Null everywhere else.
160
+ this._scope = null;
156
161
  // whether this is the tree's own top-level window rather than a nested
157
162
  // one or a popup — decided by realize(), read when it maps
158
163
  this._topLevel = false;
@@ -360,6 +365,11 @@ export class WindowNode extends Scrollable(Node) {
360
365
  attributes.eventMask = (attributes.eventMask ?? 0) | WINDOW_EVENT_MASK;
361
366
  const wnd = this.app.createWindow(attributes);
362
367
  this.window = wnd;
368
+ // What the window actually took, which is not always what was asked
369
+ // for: Cocoa puts a window on the point grid. The record is what the
370
+ // resize echo is compared against, so it has to be the size the echo
371
+ // will carry (#586, `_snapSize`).
372
+ this._requestedSize = { width: wnd.width, height: wnd.height };
363
373
  // Now that the visual is known: settle the capabilities, re-resolve any
364
374
  // `@supports` block against them, and start following the compositor.
365
375
  // Before the first paint, and before children realize against it.
@@ -422,8 +432,7 @@ export class WindowNode extends Scrollable(Node) {
422
432
  // and vanish.
423
433
  if (this.props.anchor) {
424
434
  this._watchAnchor();
425
- const node = this._anchorTarget(this.props.anchor.to);
426
- this._anchorLost = !node || anchorOffscreen(node, this.props.anchor.at);
435
+ this._anchorLost = this._anchorGone();
427
436
  }
428
437
  // Queued rather than mapped, when there is a commit to queue behind:
429
438
  // React hides a subtree only once it has inserted it (beginWindowMaps).
@@ -747,7 +756,7 @@ export class WindowNode extends Scrollable(Node) {
747
756
  // The flag `realize()`'s map will read — set directly, since there is
748
757
  // nothing on screen yet for the notification half of `_applyHidden`
749
758
  // to be about.
750
- this.hidden = this._reactHidden || Boolean(newProps.hidden);
759
+ this.hidden = this._hiddenByReact() || Boolean(newProps.hidden);
751
760
  return;
752
761
  }
753
762
 
@@ -804,10 +813,10 @@ export class WindowNode extends Scrollable(Node) {
804
813
  const geo = scaleWindowGeometry(newProps, this.scale);
805
814
  if (sizeChanged) {
806
815
  this._userSized = false;
807
- this._requestedSize = {
816
+ this._requestedSize = this._snapSize({
808
817
  width: isAutoSize(geo.width) ? wnd.width : geo.width,
809
818
  height: isAutoSize(geo.height) ? wnd.height : geo.height,
810
- };
819
+ });
811
820
  }
812
821
  if (geometryChanged) {
813
822
  if (typeof wnd.setState === 'function') {
@@ -821,8 +830,15 @@ export class WindowNode extends Scrollable(Node) {
821
830
  height: isAutoSize(geo.height) ? undefined : geo.height,
822
831
  });
823
832
  } else {
824
- if (sizeChanged && !isAutoSize(geo.width) && !isAutoSize(geo.height)) {
825
- wnd.resize?.(geo.width, geo.height);
833
+ // A window with no `setState` — Cocoa's — is resized whole or not at
834
+ // all, so an axis handed back to `'auto'` goes as the size recorded
835
+ // for it above: the one the window has, which this change leaves
836
+ // alone. Skipping the call for one `'auto'` axis dropped the other
837
+ // axis's change on the floor: `_refit()` then found the record
838
+ // already matching and sent nothing either (#585). With both axes
839
+ // `'auto'` there is no size to send, and `_refit()` works one out.
840
+ if (sizeChanged && !(isAutoSize(geo.width) && isAutoSize(geo.height))) {
841
+ wnd.resize?.(this._requestedSize.width, this._requestedSize.height);
826
842
  }
827
843
  if (movedByProps) {
828
844
  wnd.move?.(geo.x, geo.y);
@@ -887,14 +903,25 @@ export class WindowNode extends Scrollable(Node) {
887
903
  }
888
904
 
889
905
  /**
890
- * Re-derive `this.hidden` from its two writers the reconciler's flag and
891
- * the `hidden` prop and make the window agree. Either saying "hidden"
892
- * wins, so a `<Suspense>` revealing its content does not map a window the
893
- * app is holding off screen, and clearing the prop does not map one React
894
- * still hides.
906
+ * Whether React is hiding this window: its own flag, or one on the theme
907
+ * scope it is written under a `<Suspense>` around a `<ThemeProvider>` at
908
+ * the root hides the provider's node, the topmost host instance there.
909
+ * Kept apart from the window's own flag so an inner boundary that still
910
+ * hides the window is not overruled when an outer one reveals the scope.
911
+ */
912
+ _hiddenByReact() {
913
+ return this._reactHidden || (this._scope?._hiddenByReact() ?? false);
914
+ }
915
+
916
+ /**
917
+ * Re-derive `this.hidden` from its writers — React, through
918
+ * `_hiddenByReact`, and the `hidden` prop — and make the window agree.
919
+ * Either saying "hidden" wins, so a `<Suspense>` revealing its content does
920
+ * not map a window the app is holding off screen, and clearing the prop
921
+ * does not map one React still hides.
895
922
  */
896
923
  _applyHidden() {
897
- const hidden = this._reactHidden || Boolean(this.props.hidden);
924
+ const hidden = this._hiddenByReact() || Boolean(this.props.hidden);
898
925
  if (hidden === this.hidden) return;
899
926
  this.hidden = hidden;
900
927
  // An unmapped window draws nothing, so a loop inside one is frames
package/src/registry.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  DRAWN_KINDS,
29
29
  CUSTOM_SEMANTIC_NAMES,
30
30
  CUSTOM_SELF_DAMAGED,
31
+ THEME_SCOPE,
31
32
  } from './nodes/kinds.js';
32
33
  import { Node } from './nodes/node.js';
33
34
  import { markLayoutsHotReloadSession } from './layouts.js';
@@ -35,7 +36,7 @@ import { markLayoutsHotReloadSession } from './layouts.js';
35
36
  /** kind -> definition. Insertion-ordered, which is the order errors list. */
36
37
  const registry = new Map();
37
38
 
38
- const RESERVED = new Set(['textchunk', 'svgchild']);
39
+ const RESERVED = new Set(['textchunk', 'svgchild', THEME_SCOPE]);
39
40
 
40
41
  // The re-registration policy for hot reload (issue #318). Module-scope
41
42
  // registration is the pattern the docs recommend and tree-shaking forces on