react-x11 2.22.11 → 2.22.13

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.22.11",
3
+ "version": "2.22.13",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  } from './registry.js';
76
76
  import { SvgNode, SvgChildNode } from './svgnodes.js';
77
77
  import { loadLayout } from './yoga.js';
78
+ import { warmWidgetFaces } from './faces.js';
78
79
 
79
80
  // The renderer name and version DevTools shows. Read from package.json so
80
81
  // they cannot drift — but **guarded**, because a single-file bundle has no
@@ -880,6 +881,9 @@ export async function createRoot(options = {}) {
880
881
  const layout = loadLayout();
881
882
  const integrations = loadIntegrations(); // null when there is nothing to install
882
883
  const [app] = await Promise.all([connecting, layout, integrations]);
884
+ // The faces the widgets set text in beyond a family's four, matched now
885
+ // rather than in the first frame that sets one (src/faces.js).
886
+ warmWidgetFaces(app);
883
887
 
884
888
  const container = Renderer.createContainer(
885
889
  app,
package/src/atspi.js CHANGED
@@ -71,6 +71,9 @@ const NODE_PATH = '/org/a11y/atspi/accessible/';
71
71
  const NULL_PATH = '/org/a11y/atspi/null';
72
72
  const CACHE_PATH = '/org/a11y/atspi/cache';
73
73
  const REGISTRY_NAME = 'org.a11y.atspi.Registry';
74
+ const REGISTRY_PATH = '/org/a11y/atspi/registry';
75
+ const REGISTRY_IFACE = 'org.a11y.atspi.Registry';
76
+ const DBUS_NAME = 'org.freedesktop.DBus';
74
77
 
75
78
  const IFACE = {
76
79
  ACCESSIBLE: 'org.a11y.atspi.Accessible',
@@ -754,6 +757,18 @@ const EditableTextImpl = {
754
757
  // The bridge
755
758
  // --------------------------------------------------------------------------
756
759
 
760
+ /** What the renderer has said since the last push. */
761
+ function emptyPending() {
762
+ return {
763
+ attach: new Set(),
764
+ remove: [],
765
+ props: new Set(),
766
+ text: new Set(),
767
+ focus: [],
768
+ window: [],
769
+ };
770
+ }
771
+
757
772
  class AtspiBridge {
758
773
  constructor(bus, toolkitVersion) {
759
774
  this.bus = bus;
@@ -770,25 +785,32 @@ class AtspiBridge {
770
785
  this._unsubscribes = [];
771
786
  // batched renderer notifications, flushed in a microtask once the pacer
772
787
  // says so (src/a11ypace.js), and at once for what the user waits on
773
- this._pending = {
774
- attach: new Set(),
775
- remove: [],
776
- props: new Set(),
777
- text: new Set(),
778
- focus: [],
779
- window: [],
780
- };
788
+ this._pending = emptyPending();
781
789
  this._flushScheduled = false;
782
790
  this._pacer = new A11yPacer();
783
791
  /** The focused node, whose own changes are not paced. */
784
792
  this._focused = null;
793
+ /**
794
+ * Whether anything may be listening, and so whether the bridge speaks
795
+ * at all (`watchListeners`). True until the registry says nobody is.
796
+ */
797
+ this.audience = true;
798
+ /** Something has listened or asked, and the bridge speaks for good. */
799
+ this._heard = false;
800
+ /** The registry's unique name, once known: its calls are not an AT's. */
801
+ this._registryOwner = null;
802
+ /** Who has called in before the registry was asked who listens. */
803
+ this._callers = new Set();
785
804
  // Pushes are paced, reads are not: whatever the pacer is holding is
786
805
  // flushed before dbus-native dispatches an AT's call, so the objects and
787
806
  // interfaces it asks about exist as the tree has them now. A node React
788
807
  // reused as a slider still exported a button's interfaces until the
789
- // next push, and a Value read of it failed.
808
+ // next push, and a Value read of it failed. A call is also how an AT
809
+ // that registered for nothing shows it is there.
790
810
  bus.connection?.prependListener?.('message', (msg) => {
791
- if (msg.type === METHOD_CALL && this._hasPending()) this.flush();
811
+ if (msg.type !== METHOD_CALL) return;
812
+ if (msg.sender) this._calledBy(msg.sender);
813
+ if (this._hasPending()) this.flush();
792
814
  });
793
815
  }
794
816
 
@@ -1116,7 +1138,7 @@ class AtspiBridge {
1116
1138
  // ---- signals ---------------------------------------------------------
1117
1139
 
1118
1140
  _signal(path, iface, member, detail, detail1, detail2, anyData) {
1119
- if (this.dead) return;
1141
+ if (this.dead || !this.audience) return;
1120
1142
  try {
1121
1143
  this.bus.sendSignal(path, iface, member, EVENT_SIGNATURE, [
1122
1144
  detail,
@@ -1172,7 +1194,7 @@ class AtspiBridge {
1172
1194
  }
1173
1195
 
1174
1196
  emitCacheAdd(node) {
1175
- if (this.dead) return;
1197
+ if (this.dead || !this.audience) return;
1176
1198
  try {
1177
1199
  this.bus.sendSignal(
1178
1200
  CACHE_PATH,
@@ -1187,7 +1209,7 @@ class AtspiBridge {
1187
1209
  }
1188
1210
 
1189
1211
  emitCacheRemove(ref) {
1190
- if (this.dead) return;
1212
+ if (this.dead || !this.audience) return;
1191
1213
  try {
1192
1214
  this.bus.sendSignal(CACHE_PATH, IFACE.CACHE, 'RemoveAccessible', '(so)', [
1193
1215
  ref,
@@ -1207,7 +1229,7 @@ class AtspiBridge {
1207
1229
  * finds them gone, and the removal finds nothing the AT ever saw.
1208
1230
  */
1209
1231
  _schedule() {
1210
- if (this._flushScheduled || this.dead) return;
1232
+ if (this._flushScheduled || this.dead || !this.audience) return;
1211
1233
  if (this._pacer.ready(() => this.flush())) this._flushSoon();
1212
1234
  }
1213
1235
 
@@ -1240,34 +1262,43 @@ class AtspiBridge {
1240
1262
  return false;
1241
1263
  }
1242
1264
 
1265
+ // Nothing is queued with nobody to tell: the tree an AT reads when it
1266
+ // arrives is the live one, and it starts from there (`_silence`).
1267
+
1243
1268
  queueAttach(node) {
1269
+ if (!this.audience) return;
1244
1270
  this._pending.attach.add(node);
1245
1271
  this._schedule();
1246
1272
  }
1247
1273
 
1248
1274
  queueRemove(parent, child, index) {
1275
+ if (!this.audience) return;
1249
1276
  this._pending.remove.push({ parent, child, index });
1250
1277
  this._schedule();
1251
1278
  }
1252
1279
 
1253
1280
  queueProps(node) {
1281
+ if (!this.audience) return;
1254
1282
  this._pending.props.add(node);
1255
1283
  if (node === this._focused) this._urgent();
1256
1284
  else this._schedule();
1257
1285
  }
1258
1286
 
1259
1287
  queueText(node) {
1288
+ if (!this.audience) return;
1260
1289
  this._pending.text.add(node);
1261
1290
  if (node === this._focused) this._urgent();
1262
1291
  else this._schedule();
1263
1292
  }
1264
1293
 
1265
1294
  queueFocus(previous, next) {
1295
+ if (!this.audience) return;
1266
1296
  this._pending.focus.push({ previous, next });
1267
1297
  this._urgent();
1268
1298
  }
1269
1299
 
1270
1300
  queueWindowFocus(win, focused) {
1301
+ if (!this.audience) return;
1271
1302
  this._pending.window.push({ win, focused });
1272
1303
  this._urgent();
1273
1304
  }
@@ -1288,19 +1319,12 @@ class AtspiBridge {
1288
1319
  if (this.dead) return;
1289
1320
  this._flushScheduled = false;
1290
1321
  const p = this._pending;
1291
- if (!this._hasPending()) return;
1322
+ if (!this.audience || !this._hasPending()) return;
1292
1323
  // this push takes what a stream was owed, and the next interval runs
1293
1324
  // from here
1294
1325
  this._pacer.cancel();
1295
1326
  this._pacer.pushed();
1296
- this._pending = {
1297
- attach: new Set(),
1298
- remove: [],
1299
- props: new Set(),
1300
- text: new Set(),
1301
- focus: [],
1302
- window: [],
1303
- };
1327
+ this._pending = emptyPending();
1304
1328
 
1305
1329
  // Removals first: a node that left and came back in one batch reads as
1306
1330
  // its add, never as a stale remove.
@@ -1723,10 +1747,120 @@ class AtspiBridge {
1723
1747
  );
1724
1748
  this.bus.signals.on(key, (body) => {
1725
1749
  const newOwner = body?.[2];
1726
- if (newOwner) this.embed().catch(() => {});
1750
+ if (!newOwner) return;
1751
+ // a restarted registry calls in under its new name, and is still
1752
+ // not an AT
1753
+ if (this._registryOwner !== null) this._registryOwner = newOwner;
1754
+ this.embed().catch(() => {});
1727
1755
  });
1728
1756
  }
1729
1757
 
1758
+ // ---- nobody listening ------------------------------------------------
1759
+
1760
+ /**
1761
+ * Speak only with somebody to hear it. An accessibility bus runs in most
1762
+ * Linux sessions whether or not an assistive technology does, and with
1763
+ * none there every signal the bridge sends, and every object it exports
1764
+ * to name in them, is paid for by nobody: a quarter of the frame rate of
1765
+ * a thumb dragged down a 100,000-row `<Table>`.
1766
+ *
1767
+ * The registry knows. An AT registers the events it wants with it
1768
+ * (`RegisterEvent`), and it answers `GetRegisteredEvents` and announces
1769
+ * each new listener (`EventListenerRegistered`), which is how GTK 4 and
1770
+ * at-spi2-atk hold back the events nobody asked for. Both still send the
1771
+ * cache's signals and children-changed regardless, because libatspi
1772
+ * listens for those in every client without registering. So an empty list
1773
+ * is not the whole answer. An AT that registered for nothing still has to
1774
+ * call in to read anything, and a call from anybody but the registry
1775
+ * counts as well.
1776
+ *
1777
+ * With neither, the bridge falls silent: nothing queued, nothing pushed,
1778
+ * nothing exported. It speaks again for good at the first listener or the
1779
+ * first call, and an AT arriving then reads the tree as it is, which is
1780
+ * where it would start anyway. The one thing silence costs is an
1781
+ * announcement, which `announce()` then reports as unheard.
1782
+ */
1783
+ async watchListeners() {
1784
+ const bus = this.bus;
1785
+ await bus.addMatch(
1786
+ `type='signal',interface='${REGISTRY_IFACE}',` +
1787
+ "member='EventListenerRegistered'",
1788
+ );
1789
+ bus.signals.on(
1790
+ bus.mangle(REGISTRY_PATH, REGISTRY_IFACE, 'EventListenerRegistered'),
1791
+ () => this._hearFrom(),
1792
+ );
1793
+ const owner = await bus.invoke({
1794
+ destination: DBUS_NAME,
1795
+ path: '/org/freedesktop/DBus',
1796
+ interface: DBUS_NAME,
1797
+ member: 'GetNameOwner',
1798
+ signature: 's',
1799
+ body: [REGISTRY_NAME],
1800
+ });
1801
+ const events = await bus.invoke({
1802
+ destination: REGISTRY_NAME,
1803
+ path: REGISTRY_PATH,
1804
+ interface: REGISTRY_IFACE,
1805
+ member: 'GetRegisteredEvents',
1806
+ });
1807
+ // A listener that registered after the answer was sent may already
1808
+ // have been heard, in the same read as the reply.
1809
+ if (this._heard || this.dead) return;
1810
+ this._registryOwner = owner;
1811
+ const asked = [...this._callers].some((sender) => this._isPeer(sender));
1812
+ this._callers.clear();
1813
+ if (asked || !Array.isArray(events) || events.length > 0) {
1814
+ this._hearFrom();
1815
+ return;
1816
+ }
1817
+ this._silence();
1818
+ }
1819
+
1820
+ /** A method call has come in from `sender`. */
1821
+ _calledBy(sender) {
1822
+ if (this._heard) return;
1823
+ if (this._registryOwner === null) this._callers.add(sender);
1824
+ else if (this._isPeer(sender)) this._hearFrom();
1825
+ }
1826
+
1827
+ /** Anybody but the registry, the bus, or this connection. */
1828
+ _isPeer(sender) {
1829
+ return (
1830
+ sender !== this._registryOwner &&
1831
+ sender !== DBUS_NAME &&
1832
+ sender !== this.bus.name
1833
+ );
1834
+ }
1835
+
1836
+ /** Somebody listens or asks: speak, from now on. */
1837
+ _hearFrom() {
1838
+ this._heard = true;
1839
+ this._callers.clear();
1840
+ if (!this.audience && process.env.REACT_X11_A11Y) {
1841
+ console.warn('react-x11: accessibility bridge speaking — heard an AT');
1842
+ }
1843
+ this.audience = true;
1844
+ }
1845
+
1846
+ /**
1847
+ * Nobody listens: stop. What the bus was shown before the registry
1848
+ * answered goes too, so silence has nothing exported to keep in step and
1849
+ * nothing to take back.
1850
+ */
1851
+ _silence() {
1852
+ if (process.env.REACT_X11_A11Y) {
1853
+ console.warn(
1854
+ 'react-x11: accessibility bridge quiet — nothing listens or asks yet',
1855
+ );
1856
+ }
1857
+ this.audience = false;
1858
+ this._pacer.cancel();
1859
+ this._flushScheduled = false;
1860
+ this._pending = emptyPending();
1861
+ for (const node of [...this.exported.keys()]) this._unexport(node);
1862
+ }
1863
+
1730
1864
  // ---- wiring into the renderer ---------------------------------------
1731
1865
 
1732
1866
  install() {
@@ -1741,11 +1875,14 @@ class AtspiBridge {
1741
1875
  this.toplevels.splice(index, 1);
1742
1876
  this.queueRemove(null, win, index);
1743
1877
  };
1878
+ // The walks below are skipped with nobody listening: silence exports
1879
+ // nothing, so there is nothing a detach has to take back either.
1744
1880
  hooks.attached = (parent, child) => {
1745
- if (!this._live(parent)) return;
1881
+ if (!this.audience || !this._live(parent)) return;
1746
1882
  this.queueAttach(child);
1747
1883
  };
1748
1884
  hooks.detach = (parent, child) => {
1885
+ if (!this.audience) return;
1749
1886
  const exported = this.exported.has(child);
1750
1887
  if (!exported && !this._live(parent)) return;
1751
1888
  // the index is for the AT's children-changed, which only a child it
@@ -1760,6 +1897,7 @@ class AtspiBridge {
1760
1897
  if (this.exported.has(node)) this.queueProps(node);
1761
1898
  };
1762
1899
  hooks.textContent = (chunk) => {
1900
+ if (!this.audience) return;
1763
1901
  let text = chunk.parent;
1764
1902
  while (text && text.kind === 'text' && text.isSpan) text = text.parent;
1765
1903
  if (!text) return;
@@ -1784,6 +1922,9 @@ class AtspiBridge {
1784
1922
  // hook above has already asked for the one it needs
1785
1923
  hooks.commit = () => this._schedule();
1786
1924
  hooks.announce = (text, opts) => {
1925
+ // `announce()`'s false: nobody is listening, and the app may want to
1926
+ // show it instead
1927
+ if (!this.audience) return false;
1787
1928
  // spoken from the active window, which is the context the user is in
1788
1929
  const target =
1789
1930
  this.toplevels.find((win) => win.events?.windowFocused) ??
@@ -1989,6 +2130,9 @@ export async function start() {
1989
2130
  }
1990
2131
  }
1991
2132
  b.watchRegistry().catch(() => {});
2133
+ // A registry that cannot say who listens is taken to have somebody
2134
+ // listening, as GTK takes one it cannot ask.
2135
+ b.watchListeners().catch(() => b._hearFrom());
1992
2136
  b.install();
1993
2137
  bridge = b;
1994
2138
  if (loud) {
package/src/cocoa/main.js CHANGED
@@ -17,6 +17,11 @@
17
17
  // import's). Anywhere else it does nothing, so one command line serves
18
18
  // every platform: on Linux the entry runs as it would without it, and so
19
19
  // does a Worker the app starts itself (it inherits this `--import`).
20
+ //
21
+ // Of the checks it skips, it keeps one: a `<Frame>` pane stays where it is.
22
+ // The host forks the pane with the node flags it started with, this
23
+ // `--import` among them, and a pane on a worker has no IPC channel to its
24
+ // host (src/cocoa/relaunch.js, `relaunchVeto`).
20
25
  import { isMainThread, workerData } from 'node:worker_threads';
21
26
 
22
27
  import { loadNative } from './native.js';
@@ -24,7 +29,11 @@ import { bootstrapWorker, relaunch } from './relaunch.js';
24
29
 
25
30
  if (!isMainThread && workerData?.reactX11State) {
26
31
  bootstrapWorker(workerData.reactX11State);
27
- } else if (isMainThread && process.platform === 'darwin') {
32
+ } else if (
33
+ isMainThread &&
34
+ process.platform === 'darwin' &&
35
+ process.env.REACT_X11_FRAME !== '1'
36
+ ) {
28
37
  const entry = process.argv[1];
29
38
  if (!entry) {
30
39
  throw new Error(
@@ -67,6 +67,13 @@ export function relaunchVeto({
67
67
  if (env.REACT_X11_BACKEND && env.REACT_X11_BACKEND !== 'cocoa') {
68
68
  return `REACT_X11_BACKEND=${env.REACT_X11_BACKEND}`;
69
69
  }
70
+ // A `<Frame>` pane (REACT_X11_FRAME is what the host sets on the fork)
71
+ // has no AppKit for the main thread to keep — its pixels reach the host
72
+ // through shared IOSurfaces (src/cocoa/panewindow.js) — and its entry
73
+ // talks to the host over the IPC channel fork() sets up, which is the
74
+ // main thread's: on a worker there is no `process.send`, and the pane
75
+ // exited as it started (src/frame/child.js).
76
+ if (env.REACT_X11_FRAME === '1') return 'a <Frame> pane';
70
77
  // a REPL, `node -e`, or an entry that is not a file a worker can load
71
78
  if (!entry) return 'no entry script';
72
79
  // a single executable carries its entry inside the binary, where a worker
@@ -28,6 +28,7 @@ import { useGlobalMenu } from '../globalmenu.js';
28
28
  import { acceleratedItem } from '../accelerators.js';
29
29
  import { useAcceleratorEntry } from '../acceleratorhooks.js';
30
30
  import { useTopLevelWindow } from '../windowid.js';
31
+ import { MENU_TEXT_WEIGHT } from '../faces.js';
31
32
  import {
32
33
  ariaKeyShortcuts,
33
34
  checkShortcut,
@@ -212,8 +213,10 @@ const MENU_GUTTER = MENU_ICON_SIZE + MENU_ICON_GAP;
212
213
  //
213
214
  // Every label measured for a popup's width is measured at this weight too
214
215
  // (`measureLabel` takes it), since a menu sized in regular for rows drawn in
215
- // medium is a menu whose own labels run into its shortcuts.
216
- const MENU_TEXT_WEIGHT = 500;
216
+ // medium is a menu whose own labels run into its shortcuts. The weight,
217
+ // `MENU_TEXT_WEIGHT`, is in ../faces.js, which a root warms as it connects:
218
+ // a face outside a family's four is otherwise matched in the first frame
219
+ // that sets it.
217
220
 
218
221
  const MENU_SHORTCUT_GAP = 24;
219
222
  // menus size to their content rather than scrolling, so a page is a fixed
package/src/faces.js ADDED
@@ -0,0 +1,24 @@
1
+ // The faces the widgets set text in beyond the four a font source warms for
2
+ // a family by itself — regular, bold, italic and bold italic — so that a
3
+ // root can have them matched while it connects (`warmWidgetFaces`, from
4
+ // createRoot) rather than in the first frame that sets one.
5
+ //
6
+ // With fontconfig, a face nobody warmed is a synchronous `fc-match` inside
7
+ // that frame: a menu bar's titles, set in the medium below, cost 34 ms of a
8
+ // Linux desktop's first frame, and the matcher alone takes 80–150 ms on
9
+ // XQuartz. The weight is here rather than in components/Menu.js because the
10
+ // renderer is what warms it, and the renderer does not import widgets.
11
+ import { DefaultTheme } from './palette.js';
12
+
13
+ /** The weight a menu's titles and rows are set in (components/Menu.js). */
14
+ export const MENU_TEXT_WEIGHT = 500;
15
+
16
+ /**
17
+ * Start matching the widget faces in the default family, off the event loop
18
+ * (ntk's `FontManager#prewarm`). Fonts with nothing to look up — CoreText,
19
+ * DirectWrite, faces handed over in memory — ignore it, as does an ntk older
20
+ * than the method, and it never throws.
21
+ */
22
+ export function warmWidgetFaces(app) {
23
+ app.fonts?.prewarm?.(DefaultTheme.fontFamily, [{ weight: MENU_TEXT_WEIGHT }]);
24
+ }
@@ -4,12 +4,20 @@
4
4
  // `process`. Kept import-light and JSX-free on purpose: it runs before
5
5
  // anything decided what loaders this process has.
6
6
 
7
+ import { isMainThread } from 'node:worker_threads';
8
+
7
9
  import { runFrameChild } from './childmain.js';
8
10
 
9
11
  if (typeof process.send !== 'function') {
10
12
  console.error(
11
- 'react-x11: frame/child.js is the entry a <Frame> forks — it needs the ' +
12
- 'IPC channel fork() sets up, and cannot be run directly.',
13
+ isMainThread
14
+ ? 'react-x11: frame/child.js is the entry a <Frame> forks — it needs ' +
15
+ 'the IPC channel fork() sets up, and cannot be run directly.'
16
+ : // the channel is the main thread's, and something moved the pane
17
+ // off it (src/cocoa/relaunch.js keeps a pane where it is)
18
+ 'react-x11: frame/child.js is running on a worker thread, which ' +
19
+ 'has no IPC channel — a <Frame> pane has to stay on the main ' +
20
+ 'thread of its process.',
13
21
  );
14
22
  process.exit(1);
15
23
  }
@@ -66,6 +66,13 @@ export const layoutDiff = {
66
66
  // asked to: a scroll moves boxes without a pass, and the walk is what
67
67
  // carries the offset down to them (`WindowNode._walkWhole`).
68
68
  skipUnreached: false,
69
+
70
+ // The boxes a scroll pane's measure read on this walk (`contentReach`),
71
+ // left for the `absolutize` that places each one later in the same walk
72
+ // (`laidBox`): no pass runs in between, so what yoga said is what it
73
+ // says. A Map for the length of the window's walk and null outside it,
74
+ // where a pane that re-measures (`_placeNodes` moving one) reads its own.
75
+ boxes: null,
69
76
  };
70
77
 
71
78
  // What an invalidate() may name as its reason — a small closed set, so the
@@ -83,6 +83,20 @@ export function offsetInParent(node) {
83
83
  };
84
84
  }
85
85
 
86
+ /**
87
+ * Where layout put `node`'s box — yoga's `{ left, top, width, height }`,
88
+ * in one call where four getters cost four crossings into the wasm module —
89
+ * or the box a scroll pane's measure already read on this walk
90
+ * (`layoutDiff.boxes`), which no pass has touched since. A pane that
91
+ * measures its content reads every box in it, and the walk that places
92
+ * them read each one again: on an edit to a 600 KB document, 1,704 blocks'
93
+ * worth of numbers read twice every frame.
94
+ */
95
+ export function laidBox(node) {
96
+ const box = layoutDiff.boxes?.get(node);
97
+ return box === undefined ? node.yoga.getComputedLayout() : box;
98
+ }
99
+
86
100
  /** What a leaf a width pass reads nothing from answers with. */
87
101
  const UNREAD_SIZE = Object.freeze({ width: 0, height: 0 });
88
102
 
@@ -206,10 +220,11 @@ export class NodeLayout {
206
220
  // its own — counts as on screen too
207
221
  this._placed = true;
208
222
  if (!this.yoga) return;
209
- const x = originX + this.yoga.getComputedLeft();
210
- const y = originY + this.yoga.getComputedTop();
211
- const width = this.yoga.getComputedWidth();
212
- const height = this.yoga.getComputedHeight();
223
+ const box = laidBox(this);
224
+ const x = originX + box.left;
225
+ const y = originY + box.top;
226
+ const width = box.width;
227
+ const height = box.height;
213
228
  const wasX = this.abs.x;
214
229
  const wasY = this.abs.y;
215
230
  const move = this._beginRigidMove(x, y, width, height);
package/src/nodes/node.js CHANGED
@@ -108,6 +108,9 @@ export class Node {
108
108
  this._floorAtW = undefined;
109
109
  this._floorMinW = undefined;
110
110
  this._floorMinH = undefined;
111
+ // this box's top and bottom margins as a column's floor sums them
112
+ // (`marginsDown`), with the style object they were read under
113
+ this._marginsDown = null;
111
114
  // the width mode yoga last measured this leaf in with no height on
112
115
  // offer — the question `_heightForWidth` repeats
113
116
  this._floorMeasureMode = null;
@@ -17,7 +17,7 @@ import {
17
17
  XK_SPACE,
18
18
  } from '../keysyms.js';
19
19
  import { DAMAGE_SLOP, layoutDiff } from './damage.js';
20
- import { describeSize, offsetInParent } from './layout.js';
20
+ import { describeSize, laidBox, offsetInParent } from './layout.js';
21
21
  import { insetRect, intersectRects, rectsOverlap } from './rects.js';
22
22
  import {
23
23
  SCROLLBAR_WIDTH,
@@ -55,31 +55,63 @@ function contentReach(node, top) {
55
55
  cached !== undefined &&
56
56
  node._host === null &&
57
57
  node.parent?._host === null &&
58
- !node.yoga.hasNewLayout()
58
+ (!node.yoga.hasNewLayout() || childrenSettled(node, cached))
59
59
  ) {
60
60
  return cached;
61
61
  }
62
62
  let left = Infinity;
63
63
  let right = -Infinity;
64
64
  let bottom = -Infinity;
65
+ // a child a layout algorithm placed sits in its slot (`offsetInParent`)
66
+ const hosted = node._host !== null;
67
+ const boxes = layoutDiff.boxes;
65
68
  for (const child of node.children) {
66
69
  if (child.isWindow || !child.yoga || child.hidden) continue;
67
- const offset = offsetInParent(child);
68
- left = Math.min(left, offset.x);
69
- right = Math.max(right, offset.x + child.yoga.getComputedWidth());
70
- bottom = Math.max(bottom, offset.y + child.yoga.getComputedHeight());
70
+ const box = child.yoga.getComputedLayout();
71
+ // …for the walk that places this child, later in the same one
72
+ boxes?.set(child, box);
73
+ const slot = hosted ? child._hostSlot : null;
74
+ const x = slot === null ? box.left : box.left + slot.x;
75
+ const y = slot === null ? box.top : box.top + slot.y;
76
+ left = Math.min(left, x);
77
+ right = Math.max(right, x + box.width);
78
+ bottom = Math.max(bottom, y + box.height);
71
79
  if (!child.clipsChildren()) {
72
80
  const inner = contentReach(child, false);
73
- left = Math.min(left, offset.x + inner.left);
74
- right = Math.max(right, offset.x + inner.right);
75
- bottom = Math.max(bottom, offset.y + inner.bottom);
81
+ left = Math.min(left, x + inner.left);
82
+ right = Math.max(right, x + inner.right);
83
+ bottom = Math.max(bottom, y + inner.bottom);
76
84
  }
77
85
  }
78
- const reach = { left, right, bottom };
86
+ const reach = { left, right, bottom, children: node.children.length };
79
87
  if (!top) node._contentReach = reach;
80
88
  return reach;
81
89
  }
82
90
 
91
+ /**
92
+ * Whether what `node` holds sits where it did, relative to `node`: the same
93
+ * children, none of them laid out again. The flag on `node` itself says only
94
+ * that the pass reached it, and a pass reaches every child of a box it lays
95
+ * out — a column laid out again for one block that grew flags all of its
96
+ * blocks, though every one of them holds what it held, where it held it. Each
97
+ * child's own flag is the witness for that: a pass that moved, resized,
98
+ * showed, hid or clipped a child laid it out, and one that added or took a
99
+ * child away laid out its parent, which flags every child left, or changed
100
+ * the count when none was.
101
+ *
102
+ * It saves reading each child's place and size back out of yoga, four
103
+ * calls a child, to find the answer `node` already has: an edit to a 600 KB
104
+ * document was 1,704 blocks' worth of them every frame.
105
+ */
106
+ function childrenSettled(node, cached) {
107
+ const children = node.children;
108
+ if (children.length !== cached.children) return false;
109
+ for (const child of children) {
110
+ if (child.yoga && child.yoga.hasNewLayout()) return false;
111
+ }
112
+ return true;
113
+ }
114
+
83
115
  /**
84
116
  * One axis of a scroll request held for a pass
85
117
  * (`Scrollable._holdScrollTo`): `{ base, steps }`, the request the extent in
@@ -194,10 +226,11 @@ export const Scrollable = (Base) =>
194
226
  absolutize(originX, originY) {
195
227
  this._placed = true;
196
228
  if (!this.yoga) return;
197
- const x = originX + this.yoga.getComputedLeft();
198
- const y = originY + this.yoga.getComputedTop();
199
- const width = this.yoga.getComputedWidth();
200
- const height = this.yoga.getComputedHeight();
229
+ const box = laidBox(this);
230
+ const x = originX + box.left;
231
+ const y = originY + box.top;
232
+ const width = box.width;
233
+ const height = box.height;
201
234
  // a box that moved and changed nothing else claims where it was and
202
235
  // where it went, once — not once per node inside it (`Node`'s)
203
236
  const wasX = this.abs.x;
@@ -340,8 +340,7 @@ export function columnHeightSpan(node) {
340
340
  if (!inFlow(child)) continue;
341
341
  const extent = child._floorH;
342
342
  if (extent === undefined) return undefined;
343
- const top = marginDown(child.yoga, Yoga.EDGE_TOP);
344
- const bottom = marginDown(child.yoga, Yoga.EDGE_BOTTOM);
343
+ const { top, bottom } = marginsDown(child);
345
344
  if (!(top >= 0 && bottom >= 0)) return undefined;
346
345
  span += count === 0 ? extent : trailing + top + extent;
347
346
  trailing = bottom;
@@ -360,6 +359,27 @@ export function columnHeightSpan(node) {
360
359
  );
361
360
  }
362
361
 
362
+ /**
363
+ * `marginDown` for both edges of a box, kept on the box with the style object
364
+ * it was read under. A margin reaches yoga only from the style
365
+ * (`applyLayoutStyle`), and a style that changes is a new object, so while
366
+ * the object is the same, so are the margins. Asking yoga is up to six calls
367
+ * that each build an object, for every child of every column a spine sums,
368
+ * on every pass: 2 ms of an edit to a document of 1,700 blocks, for margins
369
+ * that had not changed.
370
+ */
371
+ function marginsDown(child) {
372
+ const kept = child._marginsDown;
373
+ if (kept !== null && kept.style === child.style) return kept;
374
+ const read = {
375
+ style: child.style,
376
+ top: marginDown(child.yoga, Yoga.EDGE_TOP),
377
+ bottom: marginDown(child.yoga, Yoga.EDGE_BOTTOM),
378
+ };
379
+ child._marginsDown = read;
380
+ return read;
381
+ }
382
+
363
383
  /**
364
384
  * A box's margin on one edge down the page, from its style as yoga resolves
365
385
  * it — the edge, else the vertical pair, else all four — rather than from a
@@ -161,9 +161,11 @@ export class WindowFlush {
161
161
  if (this._loopNodes.size && !this._loopWatch) this._watchLoops();
162
162
  this._advanceAnimations(now());
163
163
  if (this.needsLayout) {
164
- // before `_refit` lays anything out: a pass clears yoga's record of
165
- // which subtrees changed, and the floors are measured from that record
166
- this._collectFloorStale();
164
+ // before anything lays the tree out: a pass clears yoga's record of
165
+ // which subtrees changed, and the floors are measured from that record.
166
+ // Owed to that pass rather than taken here (`_collectOwedFloorStale`),
167
+ // so a frame whose first pass is the floors' own collects once.
168
+ this._floorsCollectOwed = true;
167
169
  this._refit();
168
170
  }
169
171
  const width = this.window.width ?? this._requestedSize?.width ?? 0;
@@ -215,6 +217,7 @@ export class WindowFlush {
215
217
  // a subtree no pass reached and nothing scrolled in is where the last
216
218
  // walk left it (`_followParent`)
217
219
  layoutDiff.skipUnreached = !this._walkWhole;
220
+ layoutDiff.boxes = new Map();
218
221
  try {
219
222
  // `_absolutizeChildren`, not the loop it wraps: a `<window
220
223
  // style={{overflow: 'scroll'}}>` is a scroll container like any box,
@@ -223,6 +226,7 @@ export class WindowFlush {
223
226
  } finally {
224
227
  layoutDiff.sink = null;
225
228
  layoutDiff.skipUnreached = false;
229
+ layoutDiff.boxes = null;
226
230
  }
227
231
  this._walkWhole = false;
228
232
  // …and placed nodes against the arrangement that walk produced: where
@@ -148,11 +148,12 @@ export class WindowSize {
148
148
  * marks the first one left.
149
149
  */
150
150
  _collectFloorStale() {
151
+ this._floorsCollectOwed = false;
151
152
  this._sweepLayoutHosts();
152
153
  const found = { width: false, height: false };
153
154
  this._floorsStale.clear();
154
155
  // Decided afresh on every call rather than once a frame: something
155
- // between the two calls a frame makes — a size query resolving — can
156
+ // between the two calls a frame can make — a size query resolving — can
156
157
  // turn a scoped change into one that is not, and the wider collection
157
158
  // is the one that is always right.
158
159
  const scope = this._floorsScope();
@@ -204,6 +205,21 @@ export class WindowSize {
204
205
  return found;
205
206
  }
206
207
 
208
+ /**
209
+ * The collection a flush owes (`_floorsCollectOwed`), taken just before
210
+ * the first pass that would clear what it reads: a natural-size
211
+ * measurement (`_refit`), or a layout that measures no floors. The floors'
212
+ * own measurement collects anyway (`_applyContentFloors`), and nothing in
213
+ * a flush before it lays anything out — a size query that resolves only
214
+ * restyles, which adds to yoga's record — so that frame collects once.
215
+ * Collecting on the way in as well read every block of a long column
216
+ * twice: 1,704 of them, a millisecond a frame, on an edit to a 600 KB
217
+ * document.
218
+ */
219
+ _collectOwedFloorStale() {
220
+ if (this._floorsCollectOwed) this._collectFloorStale();
221
+ }
222
+
207
223
  /**
208
224
  * The boxes the changes since the last measurement are confined to, or
209
225
  * null when a measurement has to look at the whole tree.
@@ -485,6 +501,7 @@ export class WindowSize {
485
501
 
486
502
  /** The real layout pass: the tree at the window's size, on the pixel grid. */
487
503
  _layoutRoot(width, height) {
504
+ this._collectOwedFloorStale();
488
505
  this._sweepLayoutHosts();
489
506
  this._layoutPasses += 1;
490
507
  this.yoga.setWidth(width);
@@ -915,6 +932,8 @@ export class WindowSize {
915
932
  isContentBound(props[key]),
916
933
  );
917
934
  if (!tracking && !bounded) return;
935
+ // the natural size is a pass over the tree
936
+ this._collectOwedFloorStale();
918
937
  const asked = this._requestedSize;
919
938
  const measured = this._measure();
920
939
  this._sendSizeHints(props, measured.hints);
@@ -166,11 +166,11 @@ export function spineScope(root, sources, listOnly) {
166
166
  // Every other child of a summed column answers from the extent it carries.
167
167
  for (const column of spine) {
168
168
  for (const child of column.children) {
169
- if (!inFlow(child) || roots.has(child) || spine.has(child)) continue;
170
- if (child._floorH === undefined) return null;
171
- if (MARGINS.some((key) => typeof child.style[key] === 'string')) {
172
- return null;
173
- }
169
+ if (!child.yoga || child.isWindow) continue;
170
+ if (roots.has(child) || spine.has(child)) continue;
171
+ const flags = styleFlags(child.style);
172
+ if (flags & OUT_OF_FLOW) continue;
173
+ if (child._floorH === undefined || flags & RELATIVE_MARGIN) return null;
174
174
  }
175
175
  }
176
176
  const depth = (node) => {
@@ -185,6 +185,33 @@ export function spineScope(root, sources, listOnly) {
185
185
  };
186
186
  }
187
187
 
188
+ const OUT_OF_FLOW = 1;
189
+ const RELATIVE_MARGIN = 2;
190
+ const STYLE_FLAGS = new WeakMap();
191
+
192
+ /**
193
+ * What the summing asks of a block's style — out of its column's flow
194
+ * (`inFlow`'s half that is style), or a margin that is not a length — kept
195
+ * per style object. A resolved style is replaced when it changes, never
196
+ * edited (`_retarget`), and a column of 1,704 blocks is a handful of
197
+ * styles asked a dozen names each, most of them unset: 20,000 lookups of
198
+ * properties no block has, on every edit to a long document.
199
+ */
200
+ function styleFlags(style) {
201
+ let flags = STYLE_FLAGS.get(style);
202
+ if (flags === undefined) {
203
+ flags =
204
+ (style.position === 'absolute' || style.display === 'none'
205
+ ? OUT_OF_FLOW
206
+ : 0) |
207
+ (MARGINS.some((key) => typeof style[key] === 'string')
208
+ ? RELATIVE_MARGIN
209
+ : 0);
210
+ STYLE_FLAGS.set(style, flags);
211
+ }
212
+ return flags;
213
+ }
214
+
188
215
  /** The children of a node that gained or lost some: the ones yoga has a
189
216
  * change on record for, and the ones never measured. */
190
217
  function staleChildren(node) {
@@ -287,6 +287,9 @@ export class WindowNode extends Scrollable(Node) {
287
287
  this._readEveryWidth = false;
288
288
  this._floorsScopeNow = null;
289
289
  this._floorsSpineNow = null;
290
+ // whether a flush's collection of what changed is still owed to the
291
+ // first pass that lays the tree out (`_collectOwedFloorStale`)
292
+ this._floorsCollectOwed = false;
290
293
  // whether the next layout walk must visit every box (`invalidate`)
291
294
  this._walkWhole = true;
292
295
  this._scopedFloorPasses = 0;
package/src/styles.js CHANGED
@@ -199,8 +199,8 @@ const LAYOUT_APPLIERS = {
199
199
  paddingStart: (n, v) => n.setPadding(Yoga.EDGE_START, v),
200
200
  paddingEnd: (n, v) => n.setPadding(Yoga.EDGE_END, v),
201
201
  gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v ?? 0),
202
- rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v ?? 0),
203
- columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v ?? 0),
202
+ rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v),
203
+ columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v),
204
204
  aspectRatio: (n, v) => n.setAspectRatio(v),
205
205
  display: (n, v) =>
206
206
  n.setDisplay(pick(DISPLAY, v, 'display') ?? Yoga.DISPLAY_FLEX),
@@ -1995,32 +1995,59 @@ export function applyLayoutDefaults(yogaNode) {
1995
1995
  yogaNode.setFlexShrink(1);
1996
1996
  }
1997
1997
 
1998
+ /** Each layout prop's applier, by name — `LAYOUT_APPLIERS` without its
1999
+ * prototype, so a style key that is not a layout prop finds nothing. */
2000
+ const LAYOUT_APPLIER_OF = new Map(Object.entries(LAYOUT_APPLIERS));
2001
+
2002
+ const NO_STYLE = Object.freeze({});
2003
+
1998
2004
  /**
1999
2005
  * Apply changed layout props to a yoga node.
2006
+ *
2007
+ * Walks the keys the two styles carry rather than every property yoga
2008
+ * knows: a style names a handful of the fifty-odd, and a property neither
2009
+ * names is `undefined` in both, which is no change. Every applier sets a
2010
+ * field of its own, so the order they run in does not matter. Asking every
2011
+ * name of both styles was most of the cost of a node that mounts — a
2012
+ * virtualized list replaces its rows at input rate.
2013
+ *
2000
2014
  * @returns true if any layout-affecting prop changed
2001
2015
  */
2002
- export function applyLayoutStyle(yogaNode, props, oldProps = {}) {
2016
+ export function applyLayoutStyle(yogaNode, props, oldProps = NO_STYLE) {
2003
2017
  let changed = false;
2004
2018
  // A placed node's insets — sticky's thresholds, or whatever a registered
2005
2019
  // position reads them as — are the placement's, never offsets: yoga gets
2006
- // `undefined` for them, and a node that becomes placed (or stops) has all
2007
- // six re-sent whether or not they changed. The flip still counts as a
2008
- // change: the pass it asks for is what places the node.
2020
+ // `undefined` for them, and a node that becomes placed (or stops) has
2021
+ // every inset it names re-sent whether or not it changed.
2009
2022
  const placed = isPlacedPosition(props.position);
2010
2023
  const flipped = placed !== isPlacedPosition(oldProps.position);
2011
- for (const key of Object.keys(LAYOUT_APPLIERS)) {
2024
+ for (const key in props) {
2025
+ const applier = LAYOUT_APPLIER_OF.get(key);
2026
+ if (applier === undefined) continue;
2027
+ const inset = INSETS.has(key);
2012
2028
  const moved =
2013
2029
  key === 'position'
2014
2030
  ? !samePosition(props.position, oldProps.position)
2015
2031
  : props[key] !== oldProps[key];
2016
- if (moved || (flipped && INSETS.has(key))) {
2017
- LAYOUT_APPLIERS[key](
2018
- yogaNode,
2019
- placed && INSETS.has(key) ? undefined : props[key],
2020
- );
2021
- changed = true;
2022
- }
2032
+ if (!moved && !(flipped && inset)) continue;
2033
+ applier(yogaNode, placed && inset ? undefined : props[key]);
2034
+ changed = true;
2035
+ }
2036
+ // …and the ones the new style dropped, which go back to yoga's default
2037
+ for (const key in oldProps) {
2038
+ if (key in props) continue;
2039
+ const applier = LAYOUT_APPLIER_OF.get(key);
2040
+ if (applier === undefined) continue;
2041
+ const moved =
2042
+ key === 'position'
2043
+ ? !samePosition(undefined, oldProps.position)
2044
+ : oldProps[key] !== undefined;
2045
+ if (!moved && !(flipped && INSETS.has(key))) continue;
2046
+ applier(yogaNode, undefined);
2047
+ changed = true;
2023
2048
  }
2049
+ // An inset neither style names is unset in yoga already, and a flip is a
2050
+ // change to `position` itself, so it is counted above.
2024
2051
  return changed;
2025
2052
  }
2026
2053
 
package/src/yoga.js CHANGED
@@ -109,6 +109,50 @@ for (const name of ['Node', 'Config']) {
109
109
 
110
110
  let loading = null;
111
111
 
112
+ /**
113
+ * The assembly, compiled from the bytes yoga-layout embeds — or null where
114
+ * they cannot be read off the disk (a bundle, a single executable: see
115
+ * docs/packaging.md), and `loadYoga()` loads it the stock way.
116
+ *
117
+ * yoga-layout ships its WebAssembly only as a base64 data URL inside its
118
+ * loader, and the loader, handed nothing, fetches that URL: Node loads undici
119
+ * for `fetch` and compiles the module through a streamed Response. That was
120
+ * 36–50 ms of every app's startup, where the same bytes handed over as
121
+ * `wasmBinary` compile in 9–13.
122
+ */
123
+ async function embeddedAssembly() {
124
+ try {
125
+ const [{ createRequire }, { readFileSync }, path, { pathToFileURL }] =
126
+ await Promise.all([
127
+ import('node:module'),
128
+ import('node:fs'),
129
+ import('node:path'),
130
+ import('node:url'),
131
+ ]);
132
+ const src = path.dirname(
133
+ createRequire(import.meta.url).resolve('yoga-layout/load'),
134
+ );
135
+ const loader = path.join(src, '..', 'binaries', 'yoga-wasm-base64-esm.js');
136
+ const embedded =
137
+ /"data:application\/octet-stream;base64,([A-Za-z0-9+/=]+)"/.exec(
138
+ readFileSync(loader, 'utf8'),
139
+ );
140
+ if (!embedded) return null;
141
+ const [{ default: factory }, { default: wrapAssembly }] = await Promise.all(
142
+ [
143
+ import(pathToFileURL(loader).href),
144
+ import(pathToFileURL(path.join(src, 'wrapAssembly.js')).href),
145
+ ],
146
+ );
147
+ return wrapAssembly(
148
+ await factory({ wasmBinary: Buffer.from(embedded[1], 'base64') }),
149
+ );
150
+ } catch {
151
+ // a layout the next yoga-layout moved, or no disk to read it from
152
+ return null;
153
+ }
154
+ }
155
+
112
156
  /**
113
157
  * Load the layout engine's WebAssembly. Idempotent, and resolves with the
114
158
  * same `Yoga` object this module exports — `createRoot()` awaits it, so
@@ -117,11 +161,13 @@ let loading = null;
117
161
  */
118
162
  export function loadLayout() {
119
163
  if (!loading) {
120
- loading = loadYoga().then((assembly) => {
121
- for (const name of ['Node', 'Config']) delete Yoga[name]; // drop the throwing getters
122
- Object.assign(Yoga, assembly);
123
- return Yoga;
124
- });
164
+ loading = embeddedAssembly()
165
+ .then((assembly) => assembly ?? loadYoga())
166
+ .then((assembly) => {
167
+ for (const name of ['Node', 'Config']) delete Yoga[name]; // drop the throwing getters
168
+ Object.assign(Yoga, assembly);
169
+ return Yoga;
170
+ });
125
171
  }
126
172
  return loading;
127
173
  }