react-x11 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,12 +79,14 @@ three:
79
79
  - **three platforms.** X11 only. macOS means XQuartz — a separate install, a
80
80
  non-native look and no menu bar integration. Windows is out; if you need
81
81
  Windows, use Electron or Tauri.
82
- - **native Wayland.** There is no Wayland backend and there is not going to
83
- be one; that would be a different renderer, not a flag. Ordinary
82
+ - **native Wayland — today.** There is no Wayland backend yet. Ordinary
84
83
  application windows work fine on a Wayland desktop through Xwayland, which
85
84
  is not going away — but the desktop-shell half of X11 (panel struts,
86
85
  global key grabs, screen capture, and the window-manager example below)
87
- needs a real X session.
86
+ needs a real X session. A native backend is researched and planned as a
87
+ **second target beside X11, not a migration**:
88
+ [docs/wayland.md](docs/wayland.md) is the RFC, from the fd transport up
89
+ to what the rendering would ride on.
88
90
  - **reusing web components.** There is no DOM. Your MUI, your Tailwind and
89
91
  your `recharts` do not come with you; the state, data-fetching, validation
90
92
  and math libraries mostly do. [docs/ecosystem.md](docs/ecosystem.md) says
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -34,6 +34,7 @@
34
34
  "xi2:probe": "node scripts/xi2-probe.mjs",
35
35
  "globalmenu:host": "node scripts/globalmenu-host.mjs",
36
36
  "devtools:proxy": "node scripts/devtools-proxy.mjs",
37
+ "examples:configurator": "tsx examples/configurator/index.jsx",
37
38
  "examples:fonts": "tsx examples/fonts.jsx",
38
39
  "examples:form": "tsx examples/form.jsx",
39
40
  "examples:rules": "tsx examples/rules.jsx",
@@ -59,12 +60,13 @@
59
60
  "stress:check": "tsx scripts/check-stress.jsx",
60
61
  "screenshots": "tsx scripts/screenshots.jsx",
61
62
  "screenshots:framed": "tsx scripts/screenshots-framed.jsx",
62
- "bench": "tsx scripts/bench/protocol.js",
63
+ "bench": "node --expose-gc --import tsx scripts/bench/protocol.js",
63
64
  "bench:pixels": "tsx scripts/bench/pixels.js",
64
65
  "bench:frames": "tsx scripts/bench/frames.js",
65
66
  "docs:dev": "npm --prefix website start",
66
67
  "docs:build": "npm --prefix website run build",
67
- "docs:test": "npm --prefix website test"
68
+ "docs:test": "npm --prefix website test",
69
+ "bench:presenters": "node --import tsx scripts/bench/presenters.js"
68
70
  },
69
71
  "repository": {
70
72
  "type": "git",
@@ -83,12 +85,14 @@
83
85
  "node": ">=20.19"
84
86
  },
85
87
  "dependencies": {
86
- "ntk": "^8.5.0",
88
+ "ntk": "^8.7.0",
87
89
  "react-reconciler": "^0.33.0",
88
90
  "yoga-layout": "^3.2.1"
89
91
  },
90
92
  "optionalDependencies": {
91
- "dbus-native": "^0.15.1"
93
+ "@windowkit/appkit": "^0.1.0",
94
+ "dbus-native": "^0.15.1",
95
+ "x11-dri": "^0.7.0"
92
96
  },
93
97
  "peerDependencies": {
94
98
  "@babel/core": "^8.0.0",
@@ -118,6 +122,9 @@
118
122
  "@babel/core": "^8.0.1",
119
123
  "@babel/plugin-transform-react-jsx": "^8.0.1",
120
124
  "@eslint/js": "^9.32.0",
125
+ "@fontsource/instrument-serif": "^5.3.0",
126
+ "@fontsource/inter": "^5.3.0",
127
+ "@fontsource/jetbrains-mono": "^5.3.0",
121
128
  "@types/react": "^19.2.17",
122
129
  "eslint": "^9.32.0",
123
130
  "eslint-plugin-react": "^7.37.5",
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,
@@ -303,15 +307,29 @@ const HostConfig = {
303
307
  // and trapFocus need commitMount too — the node has to be in the tree
304
308
  // first, so it can find the EventManager that owns focus. Drop targets
305
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.
306
316
  return (
307
317
  type === 'popup' ||
308
318
  Boolean(props.autoFocus) ||
309
319
  Boolean(props.trapFocus) ||
310
- hasDropProps(props)
320
+ hasDropProps(props) ||
321
+ (STRICT_TOKENS && instance._usesTokens)
311
322
  );
312
323
  },
313
324
 
314
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;
315
333
  if (type === 'popup') {
316
334
  instance.realize(null);
317
335
  }
@@ -553,6 +571,36 @@ async function connect(options) {
553
571
  const isNtkApp = (v) =>
554
572
  Boolean(v) && typeof v.createWindow === 'function' && typeof v.X === 'object';
555
573
 
574
+ /**
575
+ * Which display system a root that opens its own connection talks to.
576
+ *
577
+ * `'auto'` — the default — is one rule: macOS gets the native Core
578
+ * Animation backend, everything else gets X11 via `$DISPLAY`. (More rungs —
579
+ * Wayland — join this ladder, not a new one; docs/wayland.md.) On a mac
580
+ * without the `@windowkit/appkit` bridge installed, auto falls back to X11 so
581
+ * an XQuartz setup keeps working; asking for `'cocoa'` by name means the
582
+ * bridge is required, and its absence is an error that says how to fix it.
583
+ * `REACT_X11_BACKEND` overrides for A/B runs without touching code.
584
+ */
585
+ function resolveBackend(options) {
586
+ const asked = options.backend ?? process.env.REACT_X11_BACKEND ?? 'auto';
587
+ if (asked === 'x11' || asked === 'cocoa') return asked;
588
+ if (asked !== 'auto') {
589
+ throw new Error(
590
+ `react-x11: unknown backend ${JSON.stringify(asked)} — expected ` +
591
+ "'x11', 'cocoa' or 'auto'.",
592
+ );
593
+ }
594
+ // Naming an X endpoint is choosing X11: a `display` or a `stream` (the
595
+ // tests' in-process server, a tunnel) would be silently ignored by any
596
+ // other backend, and an ignored connection option is worse than either
597
+ // answer.
598
+ if (options.display !== undefined || options.stream !== undefined) {
599
+ return 'x11';
600
+ }
601
+ return process.platform === 'darwin' ? 'cocoa' : 'x11';
602
+ }
603
+
556
604
  // What a root that opens its own connection forwards to ntk. `stream` is
557
605
  // how you reach a server that is not on the other end of $DISPLAY — an
558
606
  // in-process one, a tunnel — and is what the tests connect through.
@@ -634,11 +682,24 @@ export async function createRoot(options = {}) {
634
682
  }
635
683
  const { app: borrowed, onDisconnect, ...rest } = options;
636
684
  const owned = borrowed === undefined;
685
+ // Which display system this root speaks — resolved before anything runs,
686
+ // because the desktop-integration default below depends on it. A borrowed
687
+ // app already IS a backend, so the option only steers owned connections.
688
+ const backend = owned ? resolveBackend(rest) : null;
637
689
  // Before anything starts, for two reasons: a bad `desktop` shape must throw
638
690
  // with nothing in flight, like the check above it — and `startA11y()` below
639
691
  // reads this policy, so it has to be settled before the first await, not
640
692
  // after (src/desktopintegration.js).
641
- setDesktopIntegration(rest.desktop);
693
+ //
694
+ // The cocoa backend's desktop is not freedesktop's: the AT-SPI bridge and
695
+ // the D-Bus global menu have nothing to register against there, so they
696
+ // default off (their macOS replacements are their own workstream —
697
+ // docs/macos.md). The appearance ladder stays on: its macOS rung is the
698
+ // one that answers. An explicit `desktop` option still wins.
699
+ setDesktopIntegration(
700
+ rest.desktop ??
701
+ (backend === 'cocoa' ? { a11y: false, globalMenu: false } : undefined),
702
+ );
642
703
  // The connection is started first, and the order is the point rather than a
643
704
  // detail. `loadLayout()` is only nominally asynchronous: instantiating the
644
705
  // engine blocks the event loop for 15-50 ms before it returns its promise
@@ -651,16 +712,38 @@ export async function createRoot(options = {}) {
651
712
  // synchronous block, and only where the handshake takes longer than it. It
652
713
  // is not measurable on a Unix socket, and it is lost in the noise on a link
653
714
  // slow enough to matter. This is the right order, not a fast one.
654
- const connecting = owned
655
- ? connect(
656
- Object.fromEntries(
657
- CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
658
- k,
659
- rest[k],
660
- ]),
661
- ),
662
- )
663
- : Promise.resolve(borrowed);
715
+ const connectX11 = () =>
716
+ connect(
717
+ Object.fromEntries(
718
+ CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
719
+ k,
720
+ rest[k],
721
+ ]),
722
+ ),
723
+ );
724
+ const cocoaAsked =
725
+ rest.backend === 'cocoa' || process.env.REACT_X11_BACKEND === 'cocoa';
726
+ const connecting = !owned
727
+ ? Promise.resolve(borrowed)
728
+ : backend === 'cocoa'
729
+ ? import('./cocoa/app.js')
730
+ .then(({ createCocoaApp }) => createCocoaApp(rest))
731
+ .catch((err) => {
732
+ // Asked for by name, the bridge is required and its absence is
733
+ // the error (it says how to install). Reached by 'auto', a mac
734
+ // without it falls back to X11 so an XQuartz setup keeps
735
+ // working — said once, because a silent fallback would look
736
+ // like the native backend being broken rather than absent.
737
+ if (cocoaAsked) throw err;
738
+ if (process.env.NODE_ENV !== 'production') {
739
+ console.warn(
740
+ 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
741
+ `X11 backend. (${err.message.split('\n')[0]})`,
742
+ );
743
+ }
744
+ return connectX11();
745
+ })
746
+ : connectX11();
664
747
  const layout = loadLayout();
665
748
  const integrations = loadIntegrations(); // null when there is nothing to install
666
749
  const [app] = await Promise.all([connecting, layout, integrations]);
package/src/anchor.js CHANGED
@@ -28,11 +28,20 @@ import { availableArea } from './screens.js';
28
28
  export function screenRect(node) {
29
29
  if (!node?.abs) return null;
30
30
  const origin = windowOrigin(node);
31
+ // Logical pixels, like every rect application code touches: this is a
32
+ // public export, and the rects it is compared against — `anchorRect`'s
33
+ // result, `anchorArea` — answer in logical too. The device-pixel sum
34
+ // (origin + abs) divides once, here; a caller that needs the device rect
35
+ // is placement math and multiplies back by `node.scale` itself. On a 1x
36
+ // display the two units coincide, which is how a mix survived until the
37
+ // first 2x backend ran the tooltip arrow into the wrong half of the
38
+ // bubble.
39
+ const s = node.scale ?? 1;
31
40
  return {
32
- x: origin.x + node.abs.x,
33
- y: origin.y + node.abs.y,
34
- width: node.abs.width,
35
- height: node.abs.height,
41
+ x: (origin.x + node.abs.x) / s,
42
+ y: (origin.y + node.abs.y) / s,
43
+ width: node.abs.width / s,
44
+ height: node.abs.height / s,
36
45
  };
37
46
  }
38
47
 
@@ -136,7 +145,8 @@ export function deviceAnchorArea(node) {
136
145
  const app = node?.app;
137
146
  if (!app) return null;
138
147
  const at = screenRect(node);
139
- return availableArea(app, at ? { x: at.x, y: at.y } : null);
148
+ const s = node.scale ?? 1;
149
+ return availableArea(app, at ? { x: at.x * s, y: at.y * s } : null);
140
150
  }
141
151
 
142
152
  /**
package/src/appcontext.js CHANGED
@@ -57,7 +57,16 @@ export function useApp() {
57
57
  return app;
58
58
  }
59
59
 
60
- const SUPPORTS_FEATURES = new Set(['transparency', 'shaders']);
60
+ const SUPPORTS_FEATURES = new Set([
61
+ 'transparency',
62
+ 'shaders',
63
+ 'nativeControls',
64
+ ]);
65
+
66
+ // 'nativeControls' is a property of the backend, decided before the first
67
+ // render and never changing after — so its subscription has nothing to
68
+ // deliver and its snapshot is a property test.
69
+ const NEVER_CHANGES = () => () => {};
61
70
 
62
71
  /**
63
72
  * Can this **display** do something, as a value a component can branch on?
@@ -105,6 +114,13 @@ const SUPPORTS_FEATURES = new Set(['transparency', 'shaders']);
105
114
  * disagree exactly when the policy did not ask. The capabilities also say
106
115
  * *why* this is false; see docs/gl.md.
107
116
  *
117
+ * `'nativeControls'` is true when this backend renders the platform's own
118
+ * control bezels — today the Cocoa backend, never X11. The widget set
119
+ * already branches on it through the theme's `controls: 'auto'` policy, so
120
+ * this hook is for application code composing its own controls that wants
121
+ * to sit beside native ones. It is a property of the backend and never
122
+ * changes over the app's life.
123
+ *
108
124
  * Unlike `'transparency'`, which comes and goes with the compositor, this
109
125
  * settles once and then holds still: under a policy that could pick direct,
110
126
  * `createRoot()` waits for ntk's probe before handing the app back, so the
@@ -127,18 +143,22 @@ export function useSupports(feature) {
127
143
  // side of it from disagreeing (see watchDirectGL).
128
144
  const subscribe = useCallback(
129
145
  (onChange) =>
130
- feature === 'shaders'
131
- ? watchDirectGL(app, onChange)
132
- : watchCompositing(app, onChange),
146
+ feature === 'nativeControls'
147
+ ? NEVER_CHANGES()
148
+ : feature === 'shaders'
149
+ ? watchDirectGL(app, onChange)
150
+ : watchCompositing(app, onChange),
133
151
  [app, feature],
134
152
  );
135
153
  // a boolean, so the snapshot is stable for a given state — returning the
136
154
  // visual object here would tear on every render
137
155
  const snapshot = useCallback(
138
156
  () =>
139
- feature === 'shaders'
140
- ? hasDirectGL(app)
141
- : compositingActive(app) && Boolean(argbVisual(app)),
157
+ feature === 'nativeControls'
158
+ ? Boolean(app.nativeBezels)
159
+ : feature === 'shaders'
160
+ ? hasDirectGL(app)
161
+ : compositingActive(app) && Boolean(argbVisual(app)),
142
162
  [app, feature],
143
163
  );
144
164
  return useSyncExternalStore(subscribe, snapshot, snapshot);