react-x11 2.18.3 → 2.18.4

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.18.3",
3
+ "version": "2.18.4",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -67,6 +67,7 @@ import { watchAppearance } from './appearance.js';
67
67
  import { setDesktopIntegration } from './desktopintegration.js';
68
68
  import { ForeignNode } from './foreignnodes.js';
69
69
  import { GlAreaNode } from './glnodes.js';
70
+ import { beginGlOverlay } from './gloverlay.js';
70
71
  import {
71
72
  createRegisteredNode,
72
73
  elementDefinition,
@@ -954,11 +955,17 @@ export async function createRoot(options = {}) {
954
955
  // extension; the scale ladder; the monitor layout and the work-area
955
956
  // property), so they run concurrently: each is internally a chain of one
956
957
  // to three round trips, and awaiting them in sequence made every app pay
957
- // the sum where the slowest chain is the true floor.
958
+ // the sum where the slowest chain is the true floor. The fourth is a
959
+ // single extension query, too short to set that floor: whether this is
960
+ // XQuartz, the X server nothing can be drawn over a GL surface on, which
961
+ // decides whether a `<glarea>`'s children get panes and what
962
+ // `useSupports('glOverlay')` answers on the first render
963
+ // (src/gloverlay.js).
958
964
  await Promise.all([
959
965
  beginCompositing(app),
960
966
  beginScale(app, rest.scale),
961
967
  beginScreens(app),
968
+ beginGlOverlay(app),
962
969
  ]);
963
970
 
964
971
  // How fast a caret blinks, how long a double click has, how far a press
package/src/appcontext.js CHANGED
@@ -60,9 +60,10 @@ export function useApp() {
60
60
  }
61
61
 
62
62
  // 'nativeControls', 'embedding' and 'glOverlay' are properties of the
63
- // backend, decided before the first render and never changing after — so
64
- // their subscription has nothing to deliver and their snapshot is a property
65
- // test.
63
+ // backend — and 'glOverlay' of the X server too, which `createRoot` asks
64
+ // before the first render (src/gloverlay.js) — decided before anything
65
+ // renders and never changing after, so their subscription has nothing to
66
+ // deliver and their snapshot is a property test.
66
67
  const NEVER_CHANGES = () => () => {};
67
68
 
68
69
  // What `useSupports` watches and reads, per feature. `read` answers a
@@ -169,7 +170,10 @@ const FEATURES = {
169
170
  * GL surface on this connection — laid out in its box, painted on panes over
170
171
  * the surface, hit before it. Both backends draw them; what differs is
171
172
  * translucency, composited by Core Animation on the Cocoa backend and opaque
172
- * on X11 (docs/elements.md says exactly how). It is the question to ask
173
+ * on X11 (docs/elements.md says exactly how). The exception is XQuartz,
174
+ * where it is false: the macOS window server composites every GL surface
175
+ * above everything the X server draws, so nothing can be drawn over one, and
176
+ * the children are laid out but never drawn. It is the question to ask
173
177
  * before handing a surface its HUD rather than drawing that some other way:
174
178
  *
175
179
  * ```jsx
@@ -177,7 +181,8 @@ const FEATURES = {
177
181
  * <glarea onDraw={drawMap}>{overlay && <Legend />}</glarea>
178
182
  * ```
179
183
  *
180
- * A property of the backend too, and it never changes.
184
+ * A property of the backend and the display, settled before the first
185
+ * render, and it never changes.
181
186
  */
182
187
  export function useSupports(feature) {
183
188
  const app = useApp();
package/src/glnodes.js CHANGED
@@ -10,9 +10,10 @@ import { cssColorStraight } from 'ntk';
10
10
  // re-exported so the GL element layer stays one import for consumers
11
11
  export { directGLFailure, hasDirectGL } from './glbackend.js';
12
12
 
13
- import { GlOverlay, canOverlay } from './gloverlay.js';
13
+ import { GlOverlay, canOverlay, overlayRefusal } from './gloverlay.js';
14
14
  import { layoutDiff } from './nodes/damage.js';
15
15
  import { Node } from './nodes/node.js';
16
+ import { DEV } from './nodes/util.js';
16
17
  import { FramePacer, resolveFrameRate } from './pacing.js';
17
18
 
18
19
  // One visual query per (app, spec): GetFBConfigs is a round trip and every
@@ -37,6 +38,27 @@ function recordGlxFailure(app, err) {
37
38
  if (app && err && !glxFailures.has(app)) glxFailures.set(app, err);
38
39
  }
39
40
 
41
+ // Apps already told that a <glarea>'s children are not drawn on their
42
+ // display. Once per app, as for a <foreign> that cannot embed: the answer
43
+ // belongs to the server, so the next surface to find out carries no news.
44
+ const warnedNoOverlay = new WeakSet();
45
+
46
+ /**
47
+ * Say, once per app and in development only, that the children of a
48
+ * `<glarea>` are not drawn here. They lay out and render like anyone's and
49
+ * nothing fails, so without this a HUD that never shows looks like a bug in
50
+ * the HUD.
51
+ */
52
+ function warnNoOverlay(app) {
53
+ if (!DEV || !app || warnedNoOverlay.has(app)) return;
54
+ warnedNoOverlay.add(app);
55
+ const why = overlayRefusal(app);
56
+ console.warn(
57
+ 'react-x11: the children of a <glarea> are not drawn on this connection, ' +
58
+ `and useSupports('glOverlay') is false${why ? ` — ${why}` : ''}.`,
59
+ );
60
+ }
61
+
40
62
  /**
41
63
  * The visual and depth to create the GL child window with.
42
64
  *
@@ -121,7 +143,9 @@ const px = (v) => Math.max(1, Math.round(v || 0));
121
143
  * The X child window is stacked above everything drawn in the parent, so the
122
144
  * parent's 2D content cannot overlap it — but this node's own children do:
123
145
  * they are laid out in its box like a `<box>`'s and drawn above the surface,
124
- * on panes of their own (src/gloverlay.js).
146
+ * on panes of their own (src/gloverlay.js). Everywhere but XQuartz, where
147
+ * nothing can be drawn over a GL surface: there they are laid out and not
148
+ * drawn (`canOverlay`).
125
149
  *
126
150
  * Pointer input over the surface is the tree's, on both backends: a press,
127
151
  * a drag or a wheel over it is a synthetic event at the child under the
@@ -636,7 +660,14 @@ export class GlAreaNode extends Node {
636
660
  */
637
661
  _syncOverlay() {
638
662
  if (!this._overlay) {
639
- if (this.children.length === 0 || !canOverlay(this.app)) {
663
+ // Where nothing can be drawn over the surface the children get no
664
+ // panes at all, rather than panes nobody sees: on XQuartz a pane over
665
+ // the whole surface is what hides the GL frame (src/gloverlay.js). So
666
+ // they are laid out and never drawn, and the pointer over them is the
667
+ // surface's.
668
+ const refused = this.children.length !== 0 && !canOverlay(this.app);
669
+ if (refused) warnNoOverlay(this.app);
670
+ if (this.children.length === 0 || refused) {
640
671
  this.root?._overlaid?.delete(this);
641
672
  return false;
642
673
  }
package/src/globalmenu.js CHANGED
@@ -108,6 +108,13 @@ const NO_AUTO_START = 2;
108
108
  */
109
109
  const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
110
110
 
111
+ /**
112
+ * Per window, the moment it is free: settles once every exporter that has
113
+ * published on it so far has stopped. The next one to publish there waits for
114
+ * it — see `GlobalMenuExport.takeTurn()`.
115
+ */
116
+ const vacancies = new Map();
117
+
111
118
  /**
112
119
  * The feature's off switch.
113
120
  *
@@ -167,6 +174,20 @@ export class GlobalMenuExport extends DbusMenuExport {
167
174
  this.path = null;
168
175
  this.xid = null;
169
176
  this.window = null;
177
+ /** The windows this exporter has queued for, by xid. Each is held until
178
+ * `stop()`, so publishing on one again does not queue behind itself —
179
+ * see `takeTurn()`. */
180
+ this.claimed = new Set();
181
+ /** Settles when `stop()` is called, `stopped` as a promise, so a publish
182
+ * still waiting for its window stops waiting. */
183
+ this.halted = new Promise((resolve) => {
184
+ this.halt = resolve;
185
+ });
186
+ /** Settles once `stop()` has finished, which is what the next exporter on
187
+ * a window this one claimed waits for — see `takeTurn()`. */
188
+ this.gone = new Promise((resolve) => {
189
+ this.markGone = resolve;
190
+ });
170
191
  }
171
192
 
172
193
  // ------------------------------------------------------------------ setup
@@ -281,6 +302,21 @@ export class GlobalMenuExport extends DbusMenuExport {
281
302
  // call" case rather than the "too early" one.
282
303
  if (!xid) return;
283
304
 
305
+ // One menu per window at a time — see `takeTurn()`.
306
+ const before = this.takeTurn(xid);
307
+ if (before) {
308
+ // Given up if this one is stopped meanwhile. That frees the window no
309
+ // sooner: whoever is next in line waits for `before` as well.
310
+ await Promise.race([before, this.halted]);
311
+ // How long that took is up to the menu ahead, and for a second bar in
312
+ // the window it is that bar's whole mount. So what `_sync()` decided
313
+ // before it is stale, and it decides again. A panel that went meanwhile
314
+ // would otherwise get a RegisterWindow sent to nobody, and the error
315
+ // ends `start()` in a teardown: a menu that never reaches the next
316
+ // panel either.
317
+ return this._sync();
318
+ }
319
+
284
320
  const dbus = await loadTransport();
285
321
  const path = menuPathFor(xid);
286
322
  if (!dbus.isValidObjectPath(path)) return;
@@ -320,6 +356,49 @@ export class GlobalMenuExport extends DbusMenuExport {
320
356
  this.onChange(true);
321
357
  }
322
358
 
359
+ /**
360
+ * Queue for a window, the first time this exporter publishes on it, and
361
+ * hand back what to wait for: the moment every exporter that published
362
+ * there before this one has stopped, or `null` when there is nothing to
363
+ * wait for.
364
+ *
365
+ * **One menu per window at a time.** Everything a menu holds is per window:
366
+ * the object at `menuPathFor(xid)`, the registrar's entry for the xid, and
367
+ * the two X properties. A `<MenuBar>` remounted in its window, or one with
368
+ * `globalMenu` switched off and on, puts a new exporter on all three while
369
+ * the old one is still leaving — its teardown asks `ListNames` and waits
370
+ * for `UnregisterWindow` before it unexports. That unexport is
371
+ * dbus-native's `registration.remove()`, which removes whatever is at the
372
+ * path when it runs, whoever exported it. So the new menu lost its object
373
+ * while the registrar and the properties still named it, and its bar,
374
+ * told `exported`, drew nothing: no menu anywhere. The old one's
375
+ * unregistration and property deletes race the new one's registration and
376
+ * property writes the same way. They happened to land first, which is
377
+ * harmless, but nothing made them, so the whole publish waits and not only
378
+ * the export. A second bar in a window that already has one waits too: it
379
+ * stays drawn in the window until the first one goes, rather than
380
+ * displacing it.
381
+ *
382
+ * Every one before it, not only the last: a menu stopped while it was
383
+ * still waiting is gone at once, and the one it was waiting for may not
384
+ * be.
385
+ *
386
+ * A window is held until this exporter stops, not only while it is
387
+ * published, so a panel that restarts gets the menu back from the same
388
+ * exporter without it queueing behind itself.
389
+ */
390
+ takeTurn(xid) {
391
+ if (this.claimed.has(xid)) return null;
392
+ this.claimed.add(xid);
393
+ const before = vacancies.get(xid);
394
+ const vacated = Promise.all([before, this.gone]);
395
+ vacancies.set(xid, vacated);
396
+ void vacated.then(() => {
397
+ if (vacancies.get(xid) === vacated) vacancies.delete(xid);
398
+ });
399
+ return before ?? null;
400
+ }
401
+
323
402
  /**
324
403
  * Plasma reads these instead of asking the registrar.
325
404
  *
@@ -390,15 +469,24 @@ export class GlobalMenuExport extends DbusMenuExport {
390
469
  async stop() {
391
470
  if (this.stopped) return;
392
471
  // Set first, so a publish already in flight sees it and undoes itself at
393
- // its next checkpoint rather than finishing.
472
+ // its next checkpoint rather than finishing, and one still waiting for
473
+ // its window stops waiting.
394
474
  this.stopped = true;
395
- // Then wait for it. `teardown()` reads `registration`, `xid` and `window`
396
- // across several awaits of its own, so running it *beside* a publish lets
397
- // each undo the other's work: the classic end state is `exported === true`
398
- // with the object unexported and the window unregistered — no bar in the
399
- // window, nothing in the panel, and no event left that could recover it.
400
- await this.syncing?.catch(() => {});
401
- await this.teardown();
475
+ this.halt();
476
+ try {
477
+ // Then wait for it. `teardown()` reads `registration`, `xid` and
478
+ // `window` across several awaits of its own, so running it *beside* a
479
+ // publish lets each undo the other's work: the classic end state is
480
+ // `exported === true` with the object unexported and the window
481
+ // unregistered — no bar in the window, nothing in the panel, and no
482
+ // event left that could recover it.
483
+ await this.syncing?.catch(() => {});
484
+ await this.teardown();
485
+ } finally {
486
+ // Whatever happened, the window is free: the next menu on it would
487
+ // otherwise wait for good, and never reach the panel.
488
+ this.markGone();
489
+ }
402
490
  }
403
491
 
404
492
  async teardown() {
@@ -427,6 +515,14 @@ export class GlobalMenuExport extends DbusMenuExport {
427
515
  }
428
516
  }
429
517
 
518
+ /** Test seam, not public: forget which windows are held. A previous test's
519
+ * menus are on a bus it has closed, so there is nothing to wait for, and one
520
+ * a failed test never stopped would otherwise hold its window for the rest
521
+ * of the run — every mock app numbers its first window alike. */
522
+ export function _resetGlobalMenuState() {
523
+ vacancies.clear();
524
+ }
525
+
430
526
  /**
431
527
  * Is this window's menu bar being drawn by the desktop?
432
528
  *
package/src/gloverlay.js CHANGED
@@ -35,6 +35,15 @@
35
35
  // node-x11's in-process server, where this is tested, has none — and a
36
36
  // window as big as the surface would keep a backing pixmap the size of a
37
37
  // map to show a legend in its corner.
38
+ // - **XQuartz: no, and not opaquely either.** Every GL surface there, the
39
+ // direct ones Apple-DRI exports and indirect GLX's alike, is a surface of
40
+ // the macOS window server, composited above everything the X server draws
41
+ // in the window (hw/xquartz/xpr/dri.c makes it `XP_MAPPED_ABOVE`). X's
42
+ // stacking never reaches it: a pane stacked over the surface's window is
43
+ // drawn under the frame, cutting that window down with SHAPE leaves the
44
+ // frame covering all of it, and only a pane over the whole surface shows —
45
+ // by hiding the frame (issue #653). So no pane is made there, and
46
+ // `canOverlay` says so from the first render on (`beginGlOverlay`).
38
47
  //
39
48
  // A pane selects no input, so the pointer over one reaches the owning window
40
49
  // by the same propagation that brings it the pointer over the surface
@@ -75,18 +84,73 @@ const MOVE_BLIT_MAX_REPAINT = 0.75;
75
84
  // one variable away from the plain repaint (docs/debugging.md).
76
85
  const NO_BLIT = process.env.REACT_X11_NO_SCROLL_BLIT === '1';
77
86
 
87
+ // Connections on which nothing can be drawn over a GL surface, each with the
88
+ // reason (`beginGlOverlay`), and the probe that finds out, once per app.
89
+ const refusals = new WeakMap();
90
+ const probes = new WeakMap();
91
+
92
+ const XQUARTZ =
93
+ 'this display is XQuartz, where the macOS window server composites every ' +
94
+ 'GL surface above everything the X server draws in its window. Draw the ' +
95
+ 'overlay beside the surface or in GL instead, or run on the Cocoa backend ' +
96
+ '(the default on macOS), which composites it';
97
+
78
98
  /**
79
99
  * Whether the children of a `<glarea>` can be drawn over its surface on this
80
100
  * connection: a backend that composites a pane itself, or one that can make
81
- * the plain child window a pane is on X11. One function for the element and
82
- * for `useSupports('glOverlay')`, which have to agree — the rule `canEmbed`
83
- * follows for `<foreign>`.
101
+ * the plain child window a pane is on X11 — on any server but XQuartz, where
102
+ * nothing can be drawn over a GL surface (`beginGlOverlay`). One function
103
+ * for the element and for `useSupports('glOverlay')`, which have to agree —
104
+ * the rule `canEmbed` follows for `<foreign>`.
84
105
  */
85
106
  export function canOverlay(app) {
86
- return (
87
- typeof app?.createOverlayPane === 'function' ||
88
- typeof app?.createWindow === 'function'
89
- );
107
+ if (typeof app?.createOverlayPane === 'function') return true;
108
+ return typeof app?.createWindow === 'function' && !refusals.has(app);
109
+ }
110
+
111
+ /** Why nothing is drawn over a GL surface on this connection, for the
112
+ * warning a `<glarea>` with children gives there — or null. */
113
+ export function overlayRefusal(app) {
114
+ return (app && refusals.get(app)) ?? null;
115
+ }
116
+
117
+ /**
118
+ * Is this an X server where nothing can be drawn over a GL surface? Asked
119
+ * in `createRoot`, with the other startup probes and before the first
120
+ * render, so that `canOverlay` answers the same from the first frame on —
121
+ * a component choosing how to draw by it would otherwise build one way and
122
+ * tear that down a frame later.
123
+ *
124
+ * The server is XQuartz, and the question is its Apple-DRI extension, which
125
+ * no other server has: every GL surface XQuartz makes comes from the
126
+ * machinery that extension exports, direct and indirect alike. Asked of the
127
+ * server rather than of `app.glCapabilities()`, because the default policy
128
+ * never probes direct rendering and XQuartz's indirect GLX hides the panes
129
+ * all the same. One QueryExtension; where ntk's direct-rendering probe has
130
+ * asked already, node-x11 answers it from the reply it kept.
131
+ */
132
+ export function beginGlOverlay(app) {
133
+ if (!app) return Promise.resolve();
134
+ let pending = probes.get(app);
135
+ if (pending) return pending;
136
+ const X = app.X;
137
+ pending =
138
+ typeof app.createOverlayPane === 'function' ||
139
+ typeof X?.QueryExtension !== 'function'
140
+ ? Promise.resolve()
141
+ : new Promise((resolve) => {
142
+ try {
143
+ X.QueryExtension('Apple-DRI', (err, reply) => {
144
+ if (!err && reply?.present) refusals.set(app, XQUARTZ);
145
+ resolve();
146
+ });
147
+ } catch {
148
+ // a connection that cannot ask is one this cannot find out about
149
+ resolve();
150
+ }
151
+ });
152
+ probes.set(app, pending);
153
+ return pending;
90
154
  }
91
155
 
92
156
  /** A rect grown out to whole device pixels. */
package/src/index.d.ts CHANGED
@@ -216,7 +216,10 @@ export function useClipboard(): Clipboard;
216
216
  * before it. Both backends draw them; translucency is the difference,
217
217
  * composited by Core Animation on Cocoa and opaque on X11, where what a
218
218
  * child leaves unpainted shows the surface's `clearColor` (docs/elements.md).
219
- * Ask it before handing a surface its HUD. A property of the backend.
219
+ * False on XQuartz, whose GL surfaces the macOS window server composites
220
+ * above everything the X server draws: nothing can be drawn over one there.
221
+ * Ask it before handing a surface its HUD. A property of the backend and the
222
+ * display, settled before the first render.
220
223
  */
221
224
  export type SupportsFeature =
222
225
  'transparency' | 'shaders' | 'nativeControls' | 'embedding' | 'glOverlay';
@@ -101,6 +101,13 @@ const PIXMAP_SIGNATURE = 'a(iiay)';
101
101
  * the *same* tray icon and must come back on the same path — see `stop()`. */
102
102
  let nextItemIndex = 1;
103
103
 
104
+ /**
105
+ * Per slot, the moment it is free: settles once every item that has started
106
+ * on it so far has stopped. The next item to start on the slot waits for it —
107
+ * see `start()`.
108
+ */
109
+ const vacancies = new Map();
110
+
104
111
  /** Claim a tray slot. `useTray()` takes one per hook instance, for its life. */
105
112
  export function allocateItemSlot() {
106
113
  return nextItemIndex++;
@@ -232,6 +239,11 @@ export class StatusNotifierItem {
232
239
  /** Set while withdrawing, so `Status` reads `Passive` on the way out —
233
240
  * see `announcePassive()`. */
234
241
  this.withdrawing = false;
242
+ /** Settles once `stop()` has finished, which is what the next item on
243
+ * this slot waits for — see `start()`. */
244
+ this.gone = new Promise((resolve) => {
245
+ this.markGone = resolve;
246
+ });
235
247
 
236
248
  this.ref = null;
237
249
  this.dbus = null;
@@ -252,6 +264,24 @@ export class StatusNotifierItem {
252
264
  // ------------------------------------------------------------------ setup
253
265
 
254
266
  async start() {
267
+ // **One item per slot at a time.** A hook switched off and on again puts
268
+ // its new item on the old one's path while the old one is still leaving:
269
+ // `announcePassive()` holds its object open for a moment, and its
270
+ // teardown comes after that. dbus-native's `registration.remove()`
271
+ // unexports whatever is at the path when it runs, whoever exported it,
272
+ // so a successor already there would lose its object — and its menu —
273
+ // while the watcher kept its registration: a dead icon. So an item starts
274
+ // once every item before it on the slot has stopped. Every one, not only
275
+ // the last: an item stopped while it was still waiting is gone at once,
276
+ // and the one it was waiting for may not be.
277
+ const before = vacancies.get(this.index);
278
+ const vacated = Promise.all([before, this.gone]);
279
+ vacancies.set(this.index, vacated);
280
+ void vacated.then(() => {
281
+ if (vacancies.get(this.index) === vacated) vacancies.delete(this.index);
282
+ });
283
+ await before;
284
+
255
285
  const ref = await sessionBus();
256
286
  // No bus is a first-class configuration, not a degraded one: ssh, a bare
257
287
  // startx, CI, Node 20 without the transport. There is simply no tray.
@@ -450,6 +480,8 @@ export class StatusNotifierItem {
450
480
  *
451
481
  * The pair to this is the **stable path** (`slot`): coming back re-registers
452
482
  * the same id, which a host dedupes to a reset rather than a second icon.
483
+ * The same path is why the item coming back waits for this hold, and the
484
+ * teardown after it, to finish — see `start()`.
453
485
  */
454
486
  async announcePassive() {
455
487
  if (!this.exported || !this.iface) return;
@@ -479,12 +511,18 @@ export class StatusNotifierItem {
479
511
  }
480
512
 
481
513
  async stop() {
482
- // Announced **before** `stopped`, which gates `_sync()` and every other
483
- // path that could tear the export out from under the signal.
484
- await this.announcePassive();
485
- this.stopped = true;
486
- await this.syncing;
487
- await this.teardown();
514
+ try {
515
+ // Announced **before** `stopped`, which gates `_sync()` and every other
516
+ // path that could tear the export out from under the signal.
517
+ await this.announcePassive();
518
+ this.stopped = true;
519
+ await this.syncing;
520
+ await this.teardown();
521
+ } finally {
522
+ // Whatever happened, the slot is free: the next item on it would
523
+ // otherwise wait for good, and the icon never come back.
524
+ this.markGone();
525
+ }
488
526
  }
489
527
 
490
528
  async teardown() {
@@ -746,7 +784,11 @@ export class StatusNotifierItem {
746
784
  }
747
785
  }
748
786
 
749
- /** Test seam, not public: make paths predictable across test files. */
787
+ /** Test seam, not public: make paths predictable across test files, and
788
+ * forget who held them. A previous test's items are on a bus it has closed,
789
+ * so there is nothing to wait for, and one it never stopped would otherwise
790
+ * hold its slot for the rest of the run. */
750
791
  export function _resetItemIndex() {
751
792
  nextItemIndex = 1;
793
+ vacancies.clear();
752
794
  }
package/src/svgnodes.js CHANGED
@@ -51,23 +51,57 @@ function svgAttribs(props, skip) {
51
51
  const value = props[key];
52
52
  if (key === 'children' || value == null) continue;
53
53
  if (typeof value === 'function') continue;
54
+ // React's, not the drawing's. Since React 19 a ref arrives as an
55
+ // ordinary prop, and an object one — `useRef()` — was written into the
56
+ // document as `ref="[object Object]"`. `key` would be the same if React
57
+ // ever passed it on.
58
+ if (key === 'ref' || key === 'key') continue;
54
59
  if (skip && skip(key)) continue;
55
60
  attribs[toSvgAttrib(key)] = String(value);
56
61
  }
57
62
  return attribs;
58
63
  }
59
64
 
65
+ /** Whether two `svgAttribs` results say the same thing. Order aside: the
66
+ * document reads an attribute by its name. */
67
+ function sameAttribs(a, b) {
68
+ const names = Object.keys(a);
69
+ if (names.length !== Object.keys(b).length) return false;
70
+ for (const name of names) {
71
+ if (a[name] !== b[name]) return false;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ // Props of the `<svg>` element that are react-x11's rather than the
77
+ // drawing's: `source`, the other form of the content, and what lays the
78
+ // element out, styles it and takes its input. `style` is one of them, an
79
+ // object that reached the document as "[object Object]".
80
+ const notAnAttribute = (key) =>
81
+ key === 'source' ||
82
+ key === 'style' ||
83
+ isLayoutProp(key) ||
84
+ isPaintProp(key) ||
85
+ key === 'cursor' ||
86
+ key === 'focusable' ||
87
+ key === 'pointerEvents';
88
+
60
89
  /**
61
90
  * An element inside <svg>: no yoga node, no painting of its own — it only
62
91
  * carries a tag name and props, and is serialized into the htmlparser2-style
63
- * DOM that SvgView consumes. Prop/child changes bubble to the owning
64
- * SvgNode via the _textContentChanged channel (also used by text chunks,
65
- * so string children of an SVG <text> invalidate the same way).
92
+ * DOM that SvgView consumes. A change that reaches that DOM — an attribute
93
+ * that serializes differently, a child added or removed — bubbles to the
94
+ * owning SvgNode via the _textContentChanged channel (also used by text
95
+ * chunks, so string children of an SVG <text> invalidate the same way).
66
96
  */
67
97
  export class SvgChildNode extends Node {
68
98
  constructor(tag, props, app) {
69
99
  super(tag, props, app, { yoga: false });
70
100
  this.isSvgChild = true;
101
+ /** What this element writes into the document: serialized once per
102
+ * change rather than once per rebuild, and what a commit is compared
103
+ * against. */
104
+ this._attribs = svgAttribs(props);
71
105
  }
72
106
 
73
107
  // <circle cx r fill>… is SVG's vocabulary, not the style channel's
@@ -81,14 +115,34 @@ export class SvgChildNode extends Node {
81
115
 
82
116
  applyProps(newProps, oldProps) {
83
117
  super.applyProps(newProps, oldProps);
118
+ // React commits every child of an `<svg>` it re-renders, each with a new
119
+ // props object and almost always with every attribute as it was — and a
120
+ // rebuild re-parses the whole drawing and re-measures the `<svg>`. So
121
+ // only a commit that serializes differently is a change.
122
+ const attribs = svgAttribs(newProps);
123
+ if (sameAttribs(attribs, this._attribs)) return;
124
+ this._attribs = attribs;
84
125
  this._textContentChanged();
85
126
  }
86
127
 
128
+ /**
129
+ * Nothing this node draws, whatever changed: the `<svg>` paints the whole
130
+ * document, and a change to it claims that element's box on the way
131
+ * (`SvgNode._textContentChanged`). This node has no box — its rect is an
132
+ * empty one at the window's origin — so core's answer, a claim of that
133
+ * rect whenever a prop is a new value, repainted a corner nothing here
134
+ * drew, on every commit that handed over an attribute rebuilt equal: a
135
+ * `points` or `strokeDasharray` array, say.
136
+ */
137
+ paintChanged() {
138
+ return false;
139
+ }
140
+
87
141
  toDom() {
88
142
  return {
89
143
  type: 'tag',
90
144
  name: this.kind,
91
- attribs: svgAttribs(this.props),
145
+ attribs: this._attribs,
92
146
  children: this.children
93
147
  .map((c) =>
94
148
  c.kind === 'textchunk'
@@ -114,6 +168,9 @@ export class SvgNode extends Node {
114
168
  super('svg', props, app);
115
169
  this.view = null;
116
170
  this._stale = true;
171
+ /** This element's own attributes in children form, kept like a child's
172
+ * for the same comparison. */
173
+ this._attribs = svgAttribs(props, notAnAttribute);
117
174
  /** Bumped every time the document is rebuilt, and sampled at paint time,
118
175
  * so `paintCachePlan` can tell an animated document from a settled one.
119
176
  * See the note there. */
@@ -138,7 +195,9 @@ export class SvgNode extends Node {
138
195
  );
139
196
  }
140
197
 
141
- /** Any change to the svg subtree (props, children, text) lands here. */
198
+ /** Any change to the document — `source`, an attribute, a child, a text
199
+ * chunk — lands here, and only such a change: a commit that serializes
200
+ * the same never calls it, so it rebuilds nothing and claims nothing. */
142
201
  _textContentChanged() {
143
202
  this._stale = true;
144
203
  this.invalidateMeasure('props');
@@ -155,17 +214,10 @@ export class SvgNode extends Node {
155
214
  (c) => c.isSvgChild || c.kind === 'textchunk',
156
215
  );
157
216
  if (children.length > 0) {
158
- const skip = (key) =>
159
- key === 'source' ||
160
- isLayoutProp(key) ||
161
- isPaintProp(key) ||
162
- key === 'cursor' ||
163
- key === 'focusable' ||
164
- key === 'pointerEvents';
165
217
  const dom = {
166
218
  type: 'tag',
167
219
  name: 'svg',
168
- attribs: svgAttribs(this.props, skip),
220
+ attribs: this._attribs,
169
221
  children: children.map((c) =>
170
222
  c.kind === 'textchunk' ? { type: 'text', data: c.text } : c.toDom(),
171
223
  ),
@@ -190,9 +242,18 @@ export class SvgNode extends Node {
190
242
  }
191
243
 
192
244
  applyProps(newProps, oldProps) {
245
+ const source = this.props.source;
193
246
  super.applyProps(newProps, oldProps);
194
- // attributes live in props (children form) or in `source`; either way
195
- // the SvgView is cheap to rebuild on the next measure/paint
247
+ // The document is `source`, or this element's attributes over its
248
+ // children's, whose changes arrive on their own. A commit that leaves
249
+ // both as they were — most of them: a parent re-rendering, a new style
250
+ // object, a new ref — has nothing to re-parse or re-measure. The style
251
+ // is core's to diff, like any element's.
252
+ const attribs = svgAttribs(newProps, notAnAttribute);
253
+ if (newProps.source === source && sameAttribs(attribs, this._attribs)) {
254
+ return;
255
+ }
256
+ this._attribs = attribs;
196
257
  this._textContentChanged();
197
258
  }
198
259
 
@@ -992,11 +992,12 @@ export type FrameLoop = 'demand' | 'always';
992
992
  * `<glarea>`: a GL surface in the layout. Its **children** are 2D content
993
993
  * drawn above the surface — laid out in its box like a `<box>`'s and cut to
994
994
  * it, painted over the GL frame, hit before it — composited on the Cocoa
995
- * backend and opaque on X11 (docs/elements.md); `useSupports('glOverlay')`
996
- * asks whether they will be. The pointer over it is the tree's, as over any
997
- * node: the handlers it inherits (`onMouseDown`, `onClick`, `onWheel`, …)
998
- * fire at the child under the pointer or the `<glarea>` and bubble, on both
999
- * backends, and `GlAreaNode.forwardsPointer` says so at run time.
995
+ * backend, opaque on X11 (docs/elements.md), and not drawn at all on
996
+ * XQuartz; `useSupports('glOverlay')` asks whether they will be. The
997
+ * pointer over it is the tree's, as over any node: the handlers it inherits
998
+ * (`onMouseDown`, `onClick`, `onWheel`, …) fire at the child under the
999
+ * pointer or the `<glarea>` and bubble, on both backends, and
1000
+ * `GlAreaNode.forwardsPointer` says so at run time.
1000
1001
  */
1001
1002
  export interface GlAreaProps extends DrawnProps<DrawnNode> {
1002
1003
  /** CSS colour, or `[r, g, b, a]` floats. Default black. */