react-x11 2.1.4 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.1.4",
3
+ "version": "2.2.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -34,7 +34,11 @@ import {
34
34
  } from './nodes.js';
35
35
  import { hasDropProps } from './dnd.js';
36
36
  import { AppProvider } from './appcontext.js';
37
- import { defaultRootHandlers, setErrorHandler } from './errors.js';
37
+ import {
38
+ defaultRootHandlers,
39
+ setErrorHandler,
40
+ STRICT_TOKENS,
41
+ } from './errors.js';
38
42
  import {
39
43
  registerApp,
40
44
  unregisterApp,
@@ -53,6 +57,7 @@ import { endIdle } from './idle.js';
53
57
  import { endKeyboardState } from './keyboardstate.js';
54
58
  import { endXSettings } from './xsettings.js';
55
59
  import { watchAppearance } from './appearance.js';
60
+ import { setDesktopIntegration } from './desktopintegration.js';
56
61
  import { ForeignNode } from './foreignnodes.js';
57
62
  import { GlAreaNode } from './glnodes.js';
58
63
  import { createRegisteredNode, registeredElements } from './registry.js';
@@ -302,15 +307,29 @@ const HostConfig = {
302
307
  // and trapFocus need commitMount too — the node has to be in the tree
303
308
  // first, so it can find the EventManager that owns focus. Drop targets
304
309
  // likewise: registration needs the root, which insertion assigns.
310
+ //
311
+ // Under REACT_X11_STRICT_TOKENS every token-styled node asks for one as
312
+ // well, since a bad token is only *found* once the node is attached —
313
+ // which is after this ran — and commitMount is the first moment React
314
+ // holds that node's own fiber (nodes.js `_tokenProblem`). Gated on the
315
+ // flag so the default mount pays nothing for a debugging mode.
305
316
  return (
306
317
  type === 'popup' ||
307
318
  Boolean(props.autoFocus) ||
308
319
  Boolean(props.trapFocus) ||
309
- hasDropProps(props)
320
+ hasDropProps(props) ||
321
+ (STRICT_TOKENS && instance._usesTokens)
310
322
  );
311
323
  },
312
324
 
313
325
  commitMount(instance, type, props) {
326
+ // first, and before any of the work below: the tree is on its way out.
327
+ // `false` afterwards marks this instance's one commitMount spent, so a
328
+ // later re-attach throws at once rather than deferring to a call that
329
+ // will never come (nodes.js `_tokenProblem`).
330
+ const tokenError = instance._tokenError;
331
+ instance._tokenError = false;
332
+ if (tokenError) throw tokenError;
314
333
  if (type === 'popup') {
315
334
  instance.realize(null);
316
335
  }
@@ -605,6 +624,12 @@ function watchConnection(app, onDisconnect, deliberate) {
605
624
  *
606
625
  * `display`, `fontSource`, `glxVisual` and `onXError` go straight to ntk.
607
626
  * Anything else ntk understands, build the client yourself and pass `app`.
627
+ *
628
+ * `desktop: false` turns off the three things this turns on for you that talk
629
+ * to the session bus — the appearance ladder, the accessibility bridge and
630
+ * the global menu — for an embedder that owns them, or a process that must
631
+ * not fork. `desktop: { appearance: false }` names one. See
632
+ * src/desktopintegration.js and docs/desktop.md.
608
633
  */
609
634
  export async function createRoot(options = {}) {
610
635
  // Before anything builds a node: every drawn node creates a yoga node in
@@ -627,6 +652,11 @@ export async function createRoot(options = {}) {
627
652
  }
628
653
  const { app: borrowed, onDisconnect, ...rest } = options;
629
654
  const owned = borrowed === undefined;
655
+ // Before anything starts, for two reasons: a bad `desktop` shape must throw
656
+ // with nothing in flight, like the check above it — and `startA11y()` below
657
+ // reads this policy, so it has to be settled before the first await, not
658
+ // after (src/desktopintegration.js).
659
+ setDesktopIntegration(rest.desktop);
630
660
  // The connection is started first, and the order is the point rather than a
631
661
  // detail. `loadLayout()` is only nominally asynchronous: instantiating the
632
662
  // engine blocks the event loop for 15-50 ms before it returns its promise
package/src/a11y.js CHANGED
@@ -40,6 +40,10 @@
40
40
  // from gi.repository import Atspi; \
41
41
  // print({k: int(getattr(Atspi.Role, k)) for k in dir(Atspi.Role) if not k.startswith('_')})"
42
42
 
43
+ // The one import, and it keeps this file's promise: a `Set` and two functions
44
+ // over it, with no D-Bus, no node builtins and nothing at module scope.
45
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
46
+
43
47
  /** AT-SPI role numbers (AtspiRole). */
44
48
  export const ATSPI_ROLE = Object.freeze({
45
49
  INVALID: 0,
@@ -1245,6 +1249,10 @@ let startPromise = null;
1245
1249
  * tests are exactly this case.
1246
1250
  */
1247
1251
  function a11yEnabled() {
1252
+ // `createRoot({ desktop: false })` outranks every environment variable,
1253
+ // including the one that forces the climb: it is the embedder saying this
1254
+ // process does not talk to the desktop (src/desktopintegration.js, #417).
1255
+ if (!desktopIntegrationEnabled('a11y')) return false;
1248
1256
  const own = process.env.REACT_X11_A11Y;
1249
1257
  if (own === '0') return false;
1250
1258
  if (own) return true;
package/src/appearance.js CHANGED
@@ -57,6 +57,7 @@ import os from 'node:os';
57
57
  import path from 'node:path';
58
58
 
59
59
  import { sessionBus } from './bus.js';
60
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
60
61
  import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
61
62
  import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
62
63
 
@@ -238,6 +239,12 @@ function sanitize(saved) {
238
239
  function load() {
239
240
  if (cacheChecked) return;
240
241
  cacheChecked = true;
242
+ // `createRoot({ desktop: false })` means this process does not follow the
243
+ // desktop, and that has to include the remembered answer: seeding from the
244
+ // cache would leave an app that opted out drawing in whatever colours this
245
+ // machine happened to be in last time, which is the opposite of the
246
+ // determinism the switch is asked for (#417).
247
+ if (!desktopIntegrationEnabled('appearance')) return;
241
248
  const file = cacheFile();
242
249
  if (!file) return;
243
250
  try {
@@ -702,6 +709,10 @@ async function runLadder(app) {
702
709
  */
703
710
  export function systemAppearance(options = {}) {
704
711
  if (owner) return Promise.resolve(snapshot);
712
+ // Turned off, so there is nothing to climb and nothing to remember: the
713
+ // defaults, which is a real answer — `'no-preference'` means *use your own*
714
+ // (src/desktopintegration.js).
715
+ if (!desktopIntegrationEnabled('appearance')) return Promise.resolve(NOTHING);
705
716
  load();
706
717
  if (!probe) {
707
718
  // **Failure is not cached**, for the same reason `bus.js` does not cache
package/src/atspi.js CHANGED
@@ -32,7 +32,7 @@
32
32
  // resurrected: the slots are cleared and the app simply stops being
33
33
  // accessible until restarted, the same contract bus.js documents.
34
34
 
35
- import { addressFor, loadTransport } from './bus.js';
35
+ import { loadTransport, resolveAddress } from './bus.js';
36
36
  import {
37
37
  hooks,
38
38
  ATSPI_ROLE,
@@ -1818,8 +1818,11 @@ async function connected(bus) {
1818
1818
  */
1819
1819
  async function accessibilityBusAddress(dbus) {
1820
1820
  if (process.env.AT_SPI_BUS_ADDRESS) return process.env.AT_SPI_BUS_ADDRESS;
1821
- const busAddress = addressFor('session');
1822
- if (!busAddress && process.platform !== 'darwin') return null;
1821
+ // Resolved, not read: on macOS this is where the session bus address comes
1822
+ // from, and letting `dbus-native` find it for itself would be a blocking
1823
+ // `spawnSync` on the way to the first frame (#417).
1824
+ const busAddress = await resolveAddress('session');
1825
+ if (!busAddress) return null;
1823
1826
  let sbus = null;
1824
1827
  try {
1825
1828
  sbus = dbus.createClient({ busAddress });
package/src/bus.js CHANGED
@@ -135,9 +135,8 @@ export async function loadTransport() {
135
135
  * address is incoherent under sharing. Two callers, two addresses, one
136
136
  * socket — which would win? Tests use this same seam.
137
137
  *
138
- * Not public, but exported for atspi.js, whose one-shot discovery probe
139
- * dials its own short-lived connection rather than the shared one — see
140
- * the note in `accessibilityBusAddress`.
138
+ * Synchronous, and so it cannot answer on macOS — see `resolveAddress`,
139
+ * which is what every dial goes through.
141
140
  */
142
141
  export function addressFor(kind) {
143
142
  if (kind === 'system') {
@@ -149,21 +148,102 @@ export function addressFor(kind) {
149
148
  if (process.env.DBUS_SESSION_BUS_ADDRESS) {
150
149
  return process.env.DBUS_SESSION_BUS_ADDRESS;
151
150
  }
152
- // macOS advertises the session bus through launchd, which dbus-native
153
- // already falls back to on its own. Leave it undefined and let it.
151
+ // macOS advertises the session bus through launchd rather than through the
152
+ // environment, and asking launchd is a subprocess see `launchdAddress`.
154
153
  if (process.platform === 'darwin') return undefined;
155
154
  const runtimeDir = process.env.XDG_RUNTIME_DIR;
156
155
  return runtimeDir ? `unix:path=${runtimeDir}/bus` : undefined;
157
156
  }
158
157
 
158
+ /**
159
+ * The in-flight or settled lookup, shared by every dial in the process.
160
+ *
161
+ * **Failure is cached here**, which is the one place in this module where it
162
+ * is — and the exception is deliberate. A session bus can genuinely appear
163
+ * under `$XDG_RUNTIME_DIR` mid-run, so that answer is never remembered; what
164
+ * launchd exports is a *login session* fact, set when the bus is installed
165
+ * and started, which does not happen under a running app. Re-asking would be
166
+ * a fork per feature probe to learn the same "no".
167
+ */
168
+ let launchd = null;
169
+
170
+ /**
171
+ * Ask launchd where the session bus is, **without blocking the event loop**.
172
+ *
173
+ * This is the whole reason `resolveAddress` exists (#417). `dbus-native` will
174
+ * do this lookup itself, from `createStream`, with `spawnSync` — it has to,
175
+ * because its own entry point is synchronous — and a fork+exec of `launchctl`
176
+ * on a cold page cache costs 120–150 ms of *blocked loop*, measured. Landing
177
+ * that inside `createRoot()` stalls the X handshake, the yoga instantiate and
178
+ * the first paint behind a question about the colour scheme.
179
+ *
180
+ * Every caller here is already asynchronous, so the same lookup done with
181
+ * `execFile` costs the same wall clock and none of the loop: the handshake
182
+ * and the layout engine run through it. Handing `dbus-native` the resolved
183
+ * `unix:path=…` is then what keeps it off its own synchronous path.
184
+ *
185
+ * The variable name is D-Bus's, and the fallback to our own environment is
186
+ * `launchdSocketPath`'s: a process launched from a shell that has it already
187
+ * knows the answer.
188
+ */
189
+ function launchdAddress() {
190
+ if (launchd) return launchd;
191
+ launchd = (async () => {
192
+ const VAR = 'DBUS_LAUNCHD_SESSION_BUS_SOCKET';
193
+ let fromLaunchd = '';
194
+ try {
195
+ const { execFile } = await import('node:child_process');
196
+ fromLaunchd = await new Promise((resolve) => {
197
+ execFile(
198
+ 'launchctl',
199
+ ['getenv', VAR],
200
+ { encoding: 'utf8' },
201
+ (err, stdout) => resolve(err ? '' : String(stdout).trim()),
202
+ );
203
+ });
204
+ } catch {
205
+ // no `launchctl` on $PATH, or no child processes to be had at all
206
+ }
207
+ const socket = fromLaunchd || process.env[VAR] || '';
208
+ return socket ? `unix:path=${socket}` : undefined;
209
+ })();
210
+ return launchd;
211
+ }
212
+
213
+ /**
214
+ * Where to dial, resolved — the synchronous sources first, and launchd only
215
+ * where they have nothing to say.
216
+ *
217
+ * Not public, but exported for atspi.js, whose one-shot discovery probe
218
+ * dials its own short-lived connection rather than the shared one — see the
219
+ * note in `accessibilityBusAddress`. It matters that it goes through here
220
+ * too: two dials that each let `dbus-native` ask launchd are two blocking
221
+ * forks, and the answer is the same both times.
222
+ *
223
+ * @param {BusKind} kind
224
+ * @returns {Promise<string | undefined>}
225
+ */
226
+ export async function resolveAddress(kind) {
227
+ const direct = addressFor(kind);
228
+ if (direct !== undefined) return direct;
229
+ if (kind !== 'session' || process.platform !== 'darwin') return undefined;
230
+ return await launchdAddress();
231
+ }
232
+
159
233
  function noAddressError(kind) {
234
+ if (kind !== 'session') {
235
+ return new Error(
236
+ 'react-x11: no system bus address. $DBUS_SYSTEM_BUS_ADDRESS is unset ' +
237
+ 'and there is no default.',
238
+ );
239
+ }
160
240
  return new Error(
161
- `react-x11: no ${kind} bus address. ` +
162
- (kind === 'session'
163
- ? '$DBUS_SESSION_BUS_ADDRESS is unset and $XDG_RUNTIME_DIR is not ' +
164
- 'set either, which is normal over ssh, under a bare startx and in ' +
165
- 'most containers.'
166
- : '$DBUS_SYSTEM_BUS_ADDRESS is unset and there is no default.'),
241
+ 'react-x11: no session bus address. $DBUS_SESSION_BUS_ADDRESS is unset ' +
242
+ (process.platform === 'darwin'
243
+ ? 'and launchd has no $DBUS_LAUNCHD_SESSION_BUS_SOCKET either, which ' +
244
+ 'is normal on a Mac with no D-Bus installed.'
245
+ : 'and $XDG_RUNTIME_DIR is not set either, which is normal over ssh, ' +
246
+ 'under a bare startx and in most containers.'),
167
247
  );
168
248
  }
169
249
 
@@ -216,8 +296,11 @@ async function connect(kind, generation) {
216
296
  return fail(noTransportError(cause));
217
297
  }
218
298
 
219
- const busAddress = addressFor(kind);
220
- if (!busAddress && process.platform !== 'darwin') {
299
+ // Resolved rather than read, so `dbus-native` never reaches its own
300
+ // `spawnSync` fallback on macOS (#417) — and so "this Mac has no D-Bus"
301
+ // fails here, before a socket is dialled, instead of inside a constructor.
302
+ const busAddress = await resolveAddress(kind);
303
+ if (!busAddress) {
221
304
  return fail(noAddressError(kind));
222
305
  }
223
306
 
@@ -538,6 +621,10 @@ export function busRefs(kind) {
538
621
  * exported object survives into the next test.
539
622
  */
540
623
  export function _resetBusState() {
624
+ // Including what launchd said: a suite that pins `process.platform` would
625
+ // otherwise inherit the answer — or the absence of one — from a case that
626
+ // ran under a different one.
627
+ launchd = null;
541
628
  for (const kind of KINDS) {
542
629
  state[kind] = newState(kind, state[kind].generation + 1);
543
630
  notify(kind);
@@ -0,0 +1,98 @@
1
+ // Whether this process talks to the desktop at all — the `desktop` option on
2
+ // `createRoot()`, and the one place that decides what it covers.
3
+ //
4
+ // ## What is in the group and why
5
+ //
6
+ // Three things react-x11 turns on for you without being asked, and all three
7
+ // reach the session bus:
8
+ //
9
+ // `appearance` the light/dark, accent, contrast and reduced-motion ladder
10
+ // (src/appearance.js) — the settings portal, then macOS, then
11
+ // XSETTINGS
12
+ // `a11y` the AT-SPI bridge, started from `createRoot()`
13
+ // (src/a11y.js, docs/accessibility.md)
14
+ // `globalMenu` a `MenuBar` handing its menu to the panel instead of drawing
15
+ // it (src/globalmenu.js, docs/globalmenu.md)
16
+ //
17
+ // Each already had an off switch and each of those was an **environment
18
+ // variable** — `REACT_X11_A11Y=0`, `NO_AT_BRIDGE=1`,
19
+ // `REACT_X11_NO_GLOBAL_MENU=1`, or unsetting `DBUS_SESSION_BUS_ADDRESS` for
20
+ // the appearance ladder. That is a seam an app cannot reach for itself: the
21
+ // environment is inherited, so a process that sets one before `createRoot()`
22
+ // has also set it for every child it spawns, and the D-Bus one turns off the
23
+ // portals and the app's own services along with the follower. AGENTS.md asks
24
+ // for the off switch to be somewhere the embedder can actually stand.
25
+ //
26
+ // ## Why the policy is process-wide when the option is per-root
27
+ //
28
+ // Because so is the thing it describes. There is one desktop, one D-Bus
29
+ // identity, and one AT-SPI bridge per process — `startA11y()` says so in its
30
+ // name, and appearance.js says so in its header. A per-root flag over
31
+ // process-wide state would be a seam that reads as finer than it is.
32
+ //
33
+ // So **off wins, and off latches.** A root that says `desktop: false` is a
34
+ // root with a constraint — an embedder that owns the toplevel, a test, a
35
+ // daemon that must not fork — and a second root quietly turning the feature
36
+ // back on for it would be the bug. Turning something back *on* is a thing
37
+ // this module deliberately cannot do.
38
+ //
39
+ // The one honest limit: a feature already started stays started. `startA11y()`
40
+ // is memoised per process, so a second root's `desktop: false` stops the next
41
+ // climb and not the bridge that is already up. Pass it on the first root.
42
+
43
+ /** @typedef {'appearance'|'a11y'|'globalMenu'} DesktopFeature */
44
+
45
+ /** @type {DesktopFeature[]} */
46
+ const FEATURES = ['appearance', 'a11y', 'globalMenu'];
47
+
48
+ /** What has been turned off, for the life of the process. */
49
+ const off = new Set();
50
+
51
+ /**
52
+ * Apply a root's `desktop` option. Not public — `createRoot({ desktop })` is
53
+ * the public shape.
54
+ *
55
+ * `undefined` is the default and means every feature stays on; `false` turns
56
+ * all of them off; an object names them one at a time, and a key left out is
57
+ * left alone.
58
+ *
59
+ * @param {boolean | Partial<Record<DesktopFeature, boolean>> | undefined} desktop
60
+ */
61
+ export function setDesktopIntegration(desktop) {
62
+ if (desktop === undefined || desktop === true) return;
63
+ if (desktop === false) {
64
+ for (const feature of FEATURES) off.add(feature);
65
+ return;
66
+ }
67
+ if (typeof desktop !== 'object') {
68
+ throw new TypeError(
69
+ 'react-x11: createRoot({ desktop }) takes false or an object of ' +
70
+ `${FEATURES.map((f) => `${f}: false`).join(', ')} — got ` +
71
+ `${JSON.stringify(desktop)}.`,
72
+ );
73
+ }
74
+ for (const [feature, on] of Object.entries(desktop)) {
75
+ if (!FEATURES.includes(/** @type {DesktopFeature} */ (feature))) {
76
+ throw new TypeError(
77
+ `react-x11: createRoot({ desktop: { ${feature} } }) — no such ` +
78
+ `desktop integration. Expected ${FEATURES.join(', ')}.`,
79
+ );
80
+ }
81
+ if (on === false) off.add(feature);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Whether a feature may run. Every one of the three asks this first, ahead of
87
+ * its own environment variable, so `desktop: false` is the outermost answer.
88
+ *
89
+ * @param {DesktopFeature} feature
90
+ */
91
+ export function desktopIntegrationEnabled(feature) {
92
+ return !off.has(feature);
93
+ }
94
+
95
+ /** Test seam, not public: forget the latch. */
96
+ export function _resetDesktopIntegration() {
97
+ off.clear();
98
+ }
package/src/errors.js CHANGED
@@ -59,6 +59,48 @@ export function reportHandlerError(node, handler, error) {
59
59
  markFailed();
60
60
  }
61
61
 
62
+ /**
63
+ * `REACT_X11_STRICT_TOKENS=1` makes a `$token` the theme does not define
64
+ * fatal again, for a build that would rather stop than paint something
65
+ * wrong. The default reports and carries on — see `reportStyleError`.
66
+ *
67
+ * Guarded rather than a bare `process.env` because the playground bundle
68
+ * runs in a browser, where there is no `process` at all.
69
+ */
70
+ export const STRICT_TOKENS =
71
+ (typeof process === 'undefined'
72
+ ? undefined
73
+ : process.env?.REACT_X11_STRICT_TOKENS) === '1';
74
+
75
+ /** Messages already printed, so a shared misspelled style reports once per
76
+ * (node, message) rather than once per restyle — a theme swap re-resolves
77
+ * the whole subtree and would otherwise print the same line every time. */
78
+ const reportedStyleErrors = new WeakMap();
79
+
80
+ /**
81
+ * A style the node cannot resolve — today only an unknown `$token`.
82
+ *
83
+ * Not a throw, and deliberately: the mistake is one property in one style,
84
+ * and the tree it would take down is the whole GUI. The property is dropped
85
+ * (so the widget paints without it, visibly wrong), the message names the
86
+ * token and whose element wore it, and `process.exitCode` is set so a test
87
+ * run or a supervisor still counts this as a failure. `REACT_X11_STRICT_TOKENS=1`
88
+ * restores the throw.
89
+ */
90
+ export function reportStyleError(node, message) {
91
+ const seen = reportedStyleErrors.get(node);
92
+ if (seen?.has(message)) return;
93
+ if (seen) seen.add(message);
94
+ else reportedStyleErrors.set(node, new Set([message]));
95
+ const owner = ownerName(node);
96
+ console.error(
97
+ `${message}${owner ? ` — in ${owner}` : ''}. ` +
98
+ 'The property is dropped and the app carries on; set ' +
99
+ 'REACT_X11_STRICT_TOKENS=1 to make this throw instead.',
100
+ );
101
+ markFailed();
102
+ }
103
+
62
104
  /** Wrap a call to user code so a throw is reported instead of escaping. */
63
105
  export function callHandler(node, handler, fn, ev) {
64
106
  try {
package/src/globalmenu.js CHANGED
@@ -57,6 +57,7 @@
57
57
  import { useEffect, useRef, useState } from 'react';
58
58
 
59
59
  import { loadTransport, sessionBus } from './bus.js';
60
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
60
61
  import {
61
62
  DBUSMENU_IFACE,
62
63
  PROPERTY_TYPES,
@@ -126,6 +127,10 @@ const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
126
127
  * it is the one that works without touching application code.
127
128
  */
128
129
  function globalMenuEnabled() {
130
+ // `createRoot({ desktop: false })` first: handing the menu to a panel is
131
+ // one of the three things core turns on for you, and that is the switch
132
+ // that turns the group off (src/desktopintegration.js, #417).
133
+ if (!desktopIntegrationEnabled('globalMenu')) return false;
129
134
  const flag = process.env.REACT_X11_NO_GLOBAL_MENU;
130
135
  return !flag || flag === '0';
131
136
  }
package/src/index.d.ts CHANGED
@@ -298,6 +298,43 @@ export interface RootOptions {
298
298
  * and coming back is the user's own Tab.
299
299
  */
300
300
  restoreFocusOnReveal?: boolean;
301
+ /**
302
+ * Whether this process talks to the desktop over D-Bus. On by default, and
303
+ * `false` turns off all three things react-x11 starts for you (#417,
304
+ * docs/desktop.md):
305
+ *
306
+ * | | |
307
+ * | --- | --- |
308
+ * | `appearance` | following the desktop's light/dark, accent, contrast and reduced motion (docs/appearance.md) |
309
+ * | `a11y` | the AT-SPI bridge that makes the app reachable by a screen reader (docs/accessibility.md) |
310
+ * | `globalMenu` | a `MenuBar` handing its menu to the panel instead of drawing it (docs/globalmenu.md) |
311
+ *
312
+ * ```js
313
+ * await createRoot({ desktop: false }); // none of it
314
+ * await createRoot({ desktop: { appearance: false } }); // just that one
315
+ * ```
316
+ *
317
+ * For an embedder that owns those integrations itself, a kiosk or daemon
318
+ * that must not fork a subprocess to find the bus, and a test that wants
319
+ * one answer on every machine. With all three off nothing dials the
320
+ * session bus at startup.
321
+ *
322
+ * **Off is process-wide and it latches.** There is one desktop and one
323
+ * D-Bus identity per process, so a second root cannot turn back on what
324
+ * another turned off — and a feature already started stays started, so
325
+ * pass this on the first root.
326
+ */
327
+ desktop?: boolean | DesktopIntegrationOptions;
328
+ }
329
+
330
+ /** Which desktop integrations run. See {@link CreateRootOptions.desktop}. */
331
+ export interface DesktopIntegrationOptions {
332
+ /** Follow the desktop's light/dark, accent, contrast and reduced motion. */
333
+ appearance?: boolean;
334
+ /** The AT-SPI bridge — whether a screen reader can see this app. */
335
+ a11y?: boolean;
336
+ /** Whether a `MenuBar` hands its menu to the panel. */
337
+ globalMenu?: boolean;
301
338
  }
302
339
 
303
340
  export interface ComposeOptions {
package/src/nodes.js CHANGED
@@ -109,7 +109,12 @@ import {
109
109
  windowOrigin,
110
110
  } from './anchor.js';
111
111
  import { baseTheme } from './palette.js';
112
- import { callHandler, ownerName } from './errors.js';
112
+ import {
113
+ callHandler,
114
+ ownerName,
115
+ reportStyleError,
116
+ STRICT_TOKENS,
117
+ } from './errors.js';
113
118
  import {
114
119
  hooks as a11yHooks,
115
120
  isFocusable as a11yFocusable,
@@ -1218,6 +1223,19 @@ function contentSpan(node, axis, intrinsic, out) {
1218
1223
  * one pass by `contentSpan`. `floored` collects what was written so the next
1219
1224
  * measurement can take it back off — a floor left in place would be read
1220
1225
  * back as content that cannot give, and could then only ratchet upwards.
1226
+ *
1227
+ * A floor is written **unrounded**, and the measurement it came from ran
1228
+ * with the pixel grid off (`measuringExactly`) for the reason given there:
1229
+ * rounding a floor grows the tree a pixel per nesting level. What that
1230
+ * leaves is a sharp edge in yoga worth knowing about before writing a
1231
+ * measure function. A line whose items are all held at their floors is one
1232
+ * yoga freezes item by item, subtracting each item's shrink factor from the
1233
+ * line's total as it goes; the total only cancels to zero if the sizes add
1234
+ * up exactly in binary. Three items of, say, 239.28 in a column that
1235
+ * overflows do not, and yoga divides the overflow by the rounding residue
1236
+ * instead of skipping the division — the items come back a billion pixels
1237
+ * tall (issue #411). Whole pixels cancel exactly, which is why the text
1238
+ * measures here answer in them (`TextNode._trim`).
1221
1239
  */
1222
1240
  function writeContentFloors(node, axis, mins, floored) {
1223
1241
  const axisIsMain = mainAxisOf(node) === axis;
@@ -1544,6 +1562,11 @@ export class Node {
1544
1562
  // subtree's hit reach, invalidated through _clearHitBounds()
1545
1563
  this._paintOrderCache = null;
1546
1564
  this._hitBoundsCache = null;
1565
+ // a `$token` the theme does not define, held for `commitMount` to throw
1566
+ // on this node's own fiber — see `_tokenProblem`. Strict mode only.
1567
+ // `null` is "commitMount is still to come", `false` is "it has been and
1568
+ // gone", and an Error is one waiting for it
1569
+ this._tokenError = null;
1547
1570
  this._syncStyle(props);
1548
1571
  this.yoga = yoga ? createLayoutNode() : null;
1549
1572
  if (this.yoga) {
@@ -1575,7 +1598,7 @@ export class Node {
1575
1598
  * them. `baseStyle` is the flattened `style` prop; `style` is that with
1576
1599
  * the active state blocks overlaid.
1577
1600
  */
1578
- _syncStyle(props) {
1601
+ _syncStyle(props, mounting = false) {
1579
1602
  if (DEV && this.stylable) {
1580
1603
  assertNoFlatStyleProps(props, this.kind, this.semanticNames);
1581
1604
  validateStyle(flattenStyle(props.style), `<${this.kind} style>`);
@@ -1584,12 +1607,16 @@ export class Node {
1584
1607
  this._usesTokens = this.stylable && styleUsesTokens(this._baseStyle);
1585
1608
  if (this._usesTokens) {
1586
1609
  const theme = this.theme;
1610
+ const strict = this.placed;
1611
+ const problems = strict ? [] : null;
1587
1612
  this._baseStyle = resolveTokens(
1588
1613
  this._baseStyle,
1589
1614
  theme,
1590
1615
  `<${this.kind} style>`,
1591
- this.placed,
1616
+ strict,
1617
+ problems,
1592
1618
  );
1619
+ if (problems?.length) this._tokenProblem(problems, mounting);
1593
1620
  }
1594
1621
  // `disabled` is a prop, not something the pointer does, so it is read
1595
1622
  // straight off props rather than driven by the event manager
@@ -2228,6 +2255,46 @@ export class Node {
2228
2255
  return owner.isPopup ? owner.parent != null : true;
2229
2256
  }
2230
2257
 
2258
+ /**
2259
+ * A `$token` this node's completed ancestry does not define.
2260
+ *
2261
+ * The default is `reportStyleError`: say so loudly, set `process.exitCode`,
2262
+ * and keep the property dropped. `REACT_X11_STRICT_TOKENS=1` makes it fatal
2263
+ * again, and then *where* the throw lands is the whole question — an error
2264
+ * boundary only catches what React invoked, on the fiber React thinks it
2265
+ * is working on.
2266
+ *
2267
+ * `mounting` is the attach walk, which runs inside `appendInitialChild`
2268
+ * while React is completing the nearest host *ancestor* — the `<window>`,
2269
+ * for a whole tree rendered at once. A throw there is attributed to the
2270
+ * window and sails past every boundary the app wrote inside it, which is
2271
+ * the bug this deferral exists for (#420). Stashed instead, and thrown
2272
+ * from `commitMount` on this node's own fiber, where the walk up finds a
2273
+ * boundary at any depth.
2274
+ *
2275
+ * Every other caller already has the right fiber (`commitUpdate`) or has
2276
+ * no React on the stack at all (`appearanceChanged`, from an X event) —
2277
+ * for those, throwing here is both the earliest and the only option, and
2278
+ * the second is the crash strict mode asked for.
2279
+ *
2280
+ * `commitMount` happens once per instance, so a node re-attached after it
2281
+ * has been and gone has nothing left to defer *to*; stashing there would
2282
+ * swallow the error instead of raising it late. Those throw at once, like
2283
+ * the keyed reorder they resemble.
2284
+ */
2285
+ _tokenProblem(problems, mounting) {
2286
+ if (!STRICT_TOKENS) {
2287
+ // every one of them: two misspellings in a style are two things to
2288
+ // fix, and a report that named only the first would send someone back
2289
+ // for a second run to find the second
2290
+ for (const message of problems) reportStyleError(this, message);
2291
+ return;
2292
+ }
2293
+ const error = new Error(problems[0]);
2294
+ if (mounting && this._tokenError === null) this._tokenError = error;
2295
+ else throw error;
2296
+ }
2297
+
2231
2298
  /** The owning window resized: re-resolve, since a query block may now
2232
2299
  * match that did not, or the other way round. */
2233
2300
  _sizeQueriesChanged() {
@@ -2274,7 +2341,7 @@ export class Node {
2274
2341
  if (this.isWindow) this._syncWindowBackground();
2275
2342
  if (this._usesTokens) {
2276
2343
  const before = this.style;
2277
- this._syncStyle(this.props);
2344
+ this._syncStyle(this.props, mounting);
2278
2345
  // a token change reaches the node without React re-rendering it, so
2279
2346
  // the invalidation a commit would have done has to happen here too
2280
2347
  if (localTextStyleChanged(this.style, before)) {
@@ -4587,7 +4654,12 @@ export class TextNode extends Node {
4587
4654
 
4588
4655
  /** Height for a width: the paragraph shaped into whatever is on offer.
4589
4656
  * The offer is `Infinity` when nothing bounds it, which is also what
4590
- * `textWrap: 'nowrap'` asks for, so neither needs a mode. */
4657
+ * `textWrap: 'nowrap'` asks for, so neither needs a mode.
4658
+ *
4659
+ * Both answers are **whole pixels**, the trimmed one included — see
4660
+ * `_trim` for why the rounding is not cosmetic. The glyphs are placed
4661
+ * from the unrounded trim (`_placedLayout`), so what the rounding moves
4662
+ * is the bottom edge of the box, by less than half a pixel. */
4591
4663
  measureContent({ width }) {
4592
4664
  const layout = this._layoutFor(this._wrapWidth(width));
4593
4665
  if (!layout) return { width: 0, height: 0 };
@@ -4596,7 +4668,9 @@ export class TextNode extends Node {
4596
4668
  width: Math.ceil(layout.width),
4597
4669
  height: Math.max(
4598
4670
  0,
4599
- Math.ceil(layout.height) - (trim ? trim.top + trim.bottom : 0),
4671
+ trim
4672
+ ? Math.round(Math.ceil(layout.height) - (trim.top + trim.bottom))
4673
+ : Math.ceil(layout.height),
4600
4674
  ),
4601
4675
  };
4602
4676
  }
@@ -4852,6 +4926,21 @@ export class TextNode extends Node {
4852
4926
  * Measured in the coordinates the layout is **drawn** in, not the ones it
4853
4927
  * reports: `halfLeading` shifts it, and deriving the baseline from the
4854
4928
  * metrics again would silently disagree the day that shift changes.
4929
+ *
4930
+ * The amounts are fractions of a pixel and stay that way — the glyphs are
4931
+ * placed from them (`_placedLayout`). What must not stay fractional is the
4932
+ * **box** they leave behind, which is why `measureContent` rounds the
4933
+ * height it reports and this does not (issue #411).
4934
+ *
4935
+ * A trimmed label measures to the cap band, and a cap height is a fraction
4936
+ * of the em — so before the rounding, a column of trimmed titles handed
4937
+ * yoga three or four flex items whose main size had a fraction in it and
4938
+ * whose content floors (#249) were that same fraction. Yoga freezes a line
4939
+ * like that item by item and divides the overflow by a total shrink factor
4940
+ * that should have cancelled to zero; a fraction that is not exact in
4941
+ * binary leaves a rounding residue there instead, and dividing by it laid
4942
+ * the section titles of `examples/configurator` out 5.6 billion pixels
4943
+ * tall. See `writeContentFloors`, which is the other end of it.
4855
4944
  */
4856
4945
  _trim(layout) {
4857
4946
  if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
@@ -6645,8 +6734,8 @@ export class TextInputNode extends Node {
6645
6734
  /** A preferred width, capped to whatever is on offer — `Infinity` when
6646
6735
  * nothing is, which is what makes the `Math.min` the whole rule. */
6647
6736
  measureContent({ width }) {
6648
- // Not rounded here: a trimmed `<text>` hands layout the raw cap band
6649
- // too, and rounding one of them and not the other is a pixel of
6737
+ // `_capBand` rounds, and a trimmed `<text>` rounds the same band the
6738
+ // same way rounding one of them and not the other is a pixel of
6650
6739
  // difference between a field and the button beside it.
6651
6740
  return { width: Math.min(150, width), height: this._capBand() };
6652
6741
  }
@@ -6896,12 +6985,16 @@ export class TextInputNode extends Node {
6896
6985
  };
6897
6986
  }
6898
6987
 
6988
+ /** Whole pixels either way: a field's height is a flex item's main size,
6989
+ * and a fractional one costs the tree its content floors (see
6990
+ * `TextNode._trim`, issue #411). The face with no `capHeight` to round is
6991
+ * the one that reaches the fallback. */
6899
6992
  _capBand() {
6900
6993
  const style = this.resolvedTextStyle();
6901
6994
  const cap = this.app?.fonts
6902
6995
  ?.match?.(style.family, { weight: style.weight, style: style.style })
6903
6996
  ?.metrics?.(style.size)?.capHeight;
6904
- return cap ? Math.round(cap) : this._lineHeight();
6997
+ return Math.round(cap || this._lineHeight());
6905
6998
  }
6906
6999
 
6907
7000
  /** Shaped layout of the current value, cached per (value, style,
package/src/style.d.ts CHANGED
@@ -43,12 +43,15 @@ export function tokenNames(
43
43
  style: StyleProperties,
44
44
  out?: Set<string>,
45
45
  ): Set<string>;
46
- /** Replace `$token` references with values from the theme. */
46
+ /** Replace `$token` references with values from the theme. A token the theme
47
+ * does not define is dropped either way; with `strict`, the message naming
48
+ * it is pushed onto `problems` for the caller to report or throw. */
47
49
  export function resolveTokens(
48
50
  style: StyleProperties,
49
51
  theme: Record<string, unknown> | null | undefined,
50
52
  where?: string,
51
53
  strict?: boolean,
54
+ problems?: string[] | null,
52
55
  ): StyleProperties;
53
56
 
54
57
  export function styleHasSizeQueries(style: StyleProperties): boolean;
package/src/styles.js CHANGED
@@ -1206,16 +1206,37 @@ export function stripTokens(style) {
1206
1206
  * `strict` says the node's ancestry is complete, so a token that does not
1207
1207
  * resolve is a mistake. While a subtree is still being built its nodes can
1208
1208
  * see only part of their ancestry — the theme two levels up does not exist
1209
- * for them yet — so resolution there is provisional: unknown tokens are
1210
- * dropped and the node restyles when it attaches.
1209
+ * for them yet — so resolution there is provisional.
1210
+ *
1211
+ * Both cases drop the property, because a value is either resolved or absent
1212
+ * and `'$textMuted1'` is not a colour. What `strict` changes is whether
1213
+ * anyone hears about it: mistakes are pushed onto `problems` and the caller
1214
+ * decides what one costs. Resolving itself never throws — it runs from a
1215
+ * commit and from an X event alike, and only the caller knows whether React
1216
+ * is on the stack to route a throw to a boundary (src/nodes.js).
1217
+ *
1218
+ * A cache hit replays the problems it recorded, so the second node to wear a
1219
+ * misspelled shared style is reported like the first.
1211
1220
  */
1212
- export function resolveTokens(style, theme, where = 'style', strict = true) {
1221
+ export function resolveTokens(
1222
+ style,
1223
+ theme,
1224
+ where = 'style',
1225
+ strict = true,
1226
+ problems = null,
1227
+ ) {
1213
1228
  if (!theme) return stripTokens(style);
1214
1229
  let byTheme = strict ? resolvedCache.get(style) : null;
1215
1230
  if (strict && !byTheme) resolvedCache.set(style, (byTheme = new WeakMap()));
1216
1231
  const hit = byTheme?.get(theme);
1217
- if (hit) return hit;
1232
+ if (hit) {
1233
+ if (problems && hit.problems) problems.push(...hit.problems);
1234
+ return hit.out;
1235
+ }
1218
1236
 
1237
+ // collected here rather than pushed straight to `problems` so the cache
1238
+ // entry can keep them: the caller that misses is not the only one to hear
1239
+ const found = strict ? [] : null;
1219
1240
  const out = {};
1220
1241
  for (const key of Object.keys(style)) {
1221
1242
  const v = style[key];
@@ -1225,11 +1246,7 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1225
1246
  out[key] = theme[name];
1226
1247
  continue;
1227
1248
  }
1228
- if (!strict) continue;
1229
- throw new Error(
1230
- `react-x11: unknown theme token "${v}" in ${where} ` +
1231
- `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
1232
- );
1249
+ if (strict) found.push(unknownToken(v, theme, where));
1233
1250
  } else if (mentionsToken(v)) {
1234
1251
  let unknown = null;
1235
1252
  const substituted = v.replace(TOKEN_IN_VALUE, (token) => {
@@ -1245,13 +1262,10 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1245
1262
  // frame instead of at the style.
1246
1263
  if (!unknown) out[key] = substituted;
1247
1264
  else if (strict) {
1248
- throw new Error(
1249
- `react-x11: unknown theme token "${unknown}" in ${where} ${key} ` +
1250
- `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
1251
- );
1265
+ found.push(unknownToken(unknown, theme, `${where} ${key}`));
1252
1266
  }
1253
1267
  } else if (key.charCodeAt(0) === 58 && v) {
1254
- out[key] = resolveTokens(v, theme, `${where} ${key}`, strict);
1268
+ out[key] = resolveTokens(v, theme, `${where} ${key}`, strict, found);
1255
1269
  } else if (key === 'animation' && v && typeof v === 'object') {
1256
1270
  const loops = {};
1257
1271
  let incomplete = false;
@@ -1266,11 +1280,11 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1266
1280
  theme,
1267
1281
  `${where} animation ${prop}`,
1268
1282
  strict,
1283
+ found,
1269
1284
  );
1270
- // A provisional resolution drops what it cannot resolve, which for
1271
- // an ordinary property means "not styled yet". A loop with one end
1272
- // missing is not a shorter loop, so the whole declaration waits for
1273
- // the ancestry to complete rather than throwing at a half of one.
1285
+ // A loop with one end missing is not a shorter loop, so a
1286
+ // declaration that lost a value is dropped whole rather than run
1287
+ // between a colour and nothing.
1274
1288
  if (Object.keys(resolved).length !== Object.keys(entry).length) {
1275
1289
  incomplete = true;
1276
1290
  }
@@ -1281,10 +1295,20 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1281
1295
  out[key] = v;
1282
1296
  }
1283
1297
  }
1284
- byTheme?.set(theme, out);
1298
+ if (found?.length && problems) problems.push(...found);
1299
+ byTheme?.set(theme, { out, problems: found?.length ? found : null });
1285
1300
  return out;
1286
1301
  }
1287
1302
 
1303
+ /** The one message, written once: what was named, and what the palette in
1304
+ * force actually has — listing the alternatives is most of the fix. */
1305
+ function unknownToken(token, theme, where) {
1306
+ return (
1307
+ `react-x11: unknown theme token "${token}" in ${where} ` +
1308
+ `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`
1309
+ );
1310
+ }
1311
+
1288
1312
  export { validateStyle };
1289
1313
 
1290
1314
  /**