react-x11 2.18.3 → 2.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nodes/node.js CHANGED
@@ -605,6 +605,18 @@ export class Node {
605
605
  // which is what keeps a commit from widening the region to every node
606
606
  // it touched.
607
607
  if (layoutChanged) {
608
+ const moved = onlyInsetsChanged(style, prevStyle);
609
+ // An absolutely positioned box of a set size that moved and changed
610
+ // nothing else is out of its ancestors' flow and sized by its width
611
+ // and height rather than its insets, so no content floor anywhere
612
+ // moved with it (src/nodes/invalidate.js, `'position'`).
613
+ const reason =
614
+ moved &&
615
+ style.position === 'absolute' &&
616
+ typeof style.width === 'number' &&
617
+ typeof style.height === 'number'
618
+ ? 'position'
619
+ : 'props';
608
620
  // A child of a `<glarea>` that only moved (issue #644): its pixels are
609
621
  // on a pane, one shift away, and the layout pass reports the move to
610
622
  // the overlay rather than claiming it (src/glnodes.js). A claim of the
@@ -613,12 +625,12 @@ export class Node {
613
625
  // whenever the overlay cannot move the pixels.
614
626
  if (
615
627
  this.parent?.isGlArea &&
616
- onlyInsetsChanged(style, prevStyle) &&
628
+ moved &&
617
629
  !this.paintChanged(newProps, prev)
618
630
  ) {
619
- this.invalidate(true, NO_DAMAGE, 'props');
631
+ this.invalidate(true, NO_DAMAGE, reason);
620
632
  } else {
621
- this._invalidateLayout('props');
633
+ this._invalidateLayout(reason);
622
634
  }
623
635
  } else {
624
636
  // The style half is asked here rather than inside `paintChanged`, and
@@ -970,12 +970,22 @@ export class WindowScrollBlit {
970
970
  if (ancestors.has(child)) {
971
971
  // on the path down: its solid background under the viewport is
972
972
  // translation-invariant, its border ring and corners are not.
973
- // The widest side is conservative for a non-uniform border
973
+ // The widest side is conservative for a non-uniform border.
974
+ //
975
+ // Rounded, because that is where the ring ends for what is
976
+ // inside it: at a fractional scale a 1px border is 1.25 or 1.5
977
+ // device pixels, and layout puts the children at the rounded
978
+ // inner edge — so the column the ring reaches a quarter of the
979
+ // way into is a child's, painted over whole. Tested exactly, that
980
+ // column refused every blit under a bordered box at 125%, the
981
+ // scale most Windows laptops run at.
974
982
  const bw = resolveBorderWidths(
975
983
  child.style ?? EMPTY_STYLE,
976
984
  child.direction,
977
985
  );
978
- const ring = Math.max(bw.top, bw.right, bw.bottom, bw.left);
986
+ const ring = Math.round(
987
+ Math.max(bw.top, bw.right, bw.bottom, bw.left),
988
+ );
979
989
  if (ring > 0 && !rectContains(insetRect(child.abs, ring), vp)) {
980
990
  return false;
981
991
  }
@@ -326,9 +326,26 @@ export class WindowFlush {
326
326
  // list was still taken (its bookkeeping is what keeps the two paths one
327
327
  // code) and is simply not consumed; the presenter diffs at the layer.
328
328
  if (typeof this.window.presentFrame === 'function') {
329
- // the panes are no presenter's: they paint from their damage either way
330
- if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
331
- this.window.presentFrame(this, damage);
329
+ // A presenter whose frame is the ordinary paint walk — a pass per
330
+ // damage rect into a context, the Windows window's — paints through
331
+ // the paint cache as an ntk window does, and says so
332
+ // (`usesPaintCache`). Without it every `<canvas cacheKey>` drew
333
+ // afresh in every pass that reached it, and a shadow, which is only
334
+ // ever baked into the cache (issue #413), was not drawn at all. The
335
+ // retained layer presenter paints nodes onto layers of their own and
336
+ // keeps to itself.
337
+ const cache = this.window.usesPaintCache
338
+ ? (this._paintCache ??= paintCacheFor(this.app))
339
+ : null;
340
+ cache?.beginFrame();
341
+ try {
342
+ // the panes are no presenter's: they paint from their damage either
343
+ // way
344
+ if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
345
+ this.window.presentFrame(this, damage);
346
+ } finally {
347
+ cache?.endFrame();
348
+ }
332
349
  this.app._reactX11Startup?.painted();
333
350
  return true;
334
351
  }
@@ -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. */
package/src/win32/a11y.js CHANGED
@@ -36,6 +36,22 @@ import { onApp } from '../trace-registry.js';
36
36
 
37
37
  const TRACE = process.env.REACT_X11_TRACE_A11Y === '1';
38
38
 
39
+ /**
40
+ * How often commits may push the mirror, in ms. A commit that follows a
41
+ * quiet spell pushes at once; a stream of them — a pan, a drag, a value
42
+ * ticking — pushes once per interval, the last of them catching up.
43
+ *
44
+ * UIA's clients are almost always listening on a desktop Windows (the
45
+ * touch keyboard, the text services and others ask for the tree), so the
46
+ * push is not the rare case the listening gate makes it elsewhere: a walk
47
+ * of the window and a marshalled diff, on every commit. For a graph pane
48
+ * whose every item moves on every step of a pan that was 1.6 ms of each
49
+ * frame. A screen reader reads positions on demand and does not need
50
+ * sixty of them a second; a focus change, which it is waiting on, still
51
+ * pushes at once (`hooks.focus`), as does an announcement.
52
+ */
53
+ const COMMIT_PUSH_MS = 100;
54
+
39
55
  // --------------------------------------------------------------------------
40
56
  // The one translation that is Windows' own
41
57
  // --------------------------------------------------------------------------
@@ -300,6 +316,9 @@ export class Win32Accessibility {
300
316
  this._pushed = new Set();
301
317
  this._unsubscribes = [];
302
318
  this.dead = false;
319
+ /** When a commit last pushed, and the push a stream of commits owes. */
320
+ this._lastCommitPush = -Infinity;
321
+ this._trailing = null;
303
322
  }
304
323
 
305
324
  _idOf(node) {
@@ -388,15 +407,29 @@ export class Win32Accessibility {
388
407
  * commit and paying nothing — the same shape as the AT-SPI bridge's "no
389
408
  * bus, no work".
390
409
  *
410
+ * *Nobody* is asked per window where the bridge can say (`uiaActive`):
411
+ * whether a client has read this window's tree in the last few seconds.
412
+ * UIA's own answer, `UiaClientsAreListening`, is whether any client on
413
+ * the desktop is subscribed to anything, and on a Windows running the
414
+ * touch keyboard or the text services that is always yes — so every
415
+ * commit walked and diffed the window for a mirror nobody read. A client
416
+ * that comes back after a quiet spell is noticed on its first read and
417
+ * asks for a fresh push (`wanted`). `global` keeps the desktop-wide answer
418
+ * for a focus change and an announcement: a client subscribed to focus
419
+ * events reads the focused element the moment it hears of it, before
420
+ * any read of ours could say it was there.
421
+ *
391
422
  * With one exception, which is what makes the gate safe: **every window is
392
423
  * pushed once regardless.** A client's first `WM_GETOBJECT` has to find a
393
424
  * tree, and it arrives before anything in this process knows a client
394
425
  * exists. After that first push the mirror is live, and a client attaching
395
426
  * later asks for a fresh one (`uia-wanted`).
396
427
  */
397
- flush({ force = false } = {}) {
428
+ flush({ force = false, global = false } = {}) {
398
429
  if (this.dead || this._dirty.size === 0) return;
399
- const listening = force || this._native.uiaListening();
430
+ const perWindow =
431
+ !force && !global && typeof this._native.uiaActive === 'function';
432
+ const listening = force || (!perWindow && this._native.uiaListening());
400
433
  const windows = [...this._dirty];
401
434
  this._dirty.clear();
402
435
  for (const win of windows) {
@@ -409,12 +442,37 @@ export class Win32Accessibility {
409
442
  this._dirty.add(win);
410
443
  continue;
411
444
  }
412
- if (!listening && this._pushed.has(wnd.id)) continue;
445
+ if (
446
+ !listening &&
447
+ this._pushed.has(wnd.id) &&
448
+ !(perWindow && this._native.uiaActive(wnd.id))
449
+ ) {
450
+ continue;
451
+ }
413
452
  this._pushed.add(wnd.id);
414
453
  this._push(win);
415
454
  }
416
455
  }
417
456
 
457
+ /** A commit's push, at most once per `COMMIT_PUSH_MS`. */
458
+ _commitFlush() {
459
+ if (this.dead || this._dirty.size === 0) return;
460
+ const now = performance.now();
461
+ const wait = this._lastCommitPush + COMMIT_PUSH_MS - now;
462
+ if (wait <= 0) {
463
+ this._lastCommitPush = now;
464
+ this.flush();
465
+ return;
466
+ }
467
+ if (this._trailing) return;
468
+ this._trailing = setTimeout(() => {
469
+ this._trailing = null;
470
+ this._lastCommitPush = performance.now();
471
+ this.flush();
472
+ }, wait);
473
+ this._trailing.unref?.();
474
+ }
475
+
418
476
  /** A window's HWND exists now, so what is owed for it can be pushed. */
419
477
  windowReady(wnd) {
420
478
  const win = this.toplevels.find((w) => w.window === wnd);
@@ -546,12 +604,12 @@ export class Win32Accessibility {
546
604
  // Pushed now rather than at the next commit: a focus change is what a
547
605
  // screen reader is waiting for, and the event has to follow an update
548
606
  // that already carries the new state or the client reads the old one.
549
- this.flush();
607
+ this.flush({ global: true });
550
608
  const wnd = this._windowOf(win);
551
609
  if (wnd && next) this._native.uiaFocusChanged(wnd.id, this._idOf(next));
552
610
  };
553
611
  hooks.windowFocus = (win) => this._dirty.add(win);
554
- hooks.commit = () => this.flush();
612
+ hooks.commit = () => this._commitFlush();
555
613
  hooks.announce = (text, opts) => {
556
614
  const win =
557
615
  this.toplevels.find((w) => w.events?.windowFocused) ??
@@ -562,7 +620,7 @@ export class Win32Accessibility {
562
620
  // The window must be in the mirror before anything can be announced
563
621
  // from it, which on the first announcement of a session it may not be.
564
622
  this._dirty.add(win);
565
- this.flush();
623
+ this.flush({ global: true });
566
624
  return Boolean(
567
625
  this._native.uiaAnnounce(wnd.id, String(text), !opts?.assertive),
568
626
  );
@@ -579,6 +637,8 @@ export class Win32Accessibility {
579
637
  bury() {
580
638
  if (this.dead) return;
581
639
  this.dead = true;
640
+ if (this._trailing) clearTimeout(this._trailing);
641
+ this._trailing = null;
582
642
  for (const key of Object.keys(hooks)) hooks[key] = null;
583
643
  for (const unsubscribe of this._unsubscribes) unsubscribe();
584
644
  this._unsubscribes = [];