react-x11 2.49.0 → 2.51.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
@@ -464,6 +464,10 @@ npx react-devtools # 1. start the standalone UI
464
464
  REACT_X11_DEVTOOLS=1 npm run examples:dashboard # 2. run any example with the bridge on
465
465
  ```
466
466
 
467
+ Under `npx react-x11 dev` the bridge is on by default (with
468
+ `react-devtools-core` installed): run `npx react-devtools`, before or after,
469
+ and the app shows up.
470
+
467
471
  The component tree, props and hook state show up live in the DevTools
468
472
  window, and editing any of them re-renders the app. Hovering an element in
469
473
  the tree tints its rect in the app's window; the toolbar's crosshair picks
@@ -498,7 +502,7 @@ Alt+Click any rendered element (Option+Click on macOS, either backend) to
498
502
  open the JSX line that created it in your editor. `REACT_X11_EDITOR` picks
499
503
  the CLI and enables the feature in one go; `REACT_X11_CLICK_TO_COMPONENT=1`
500
504
  does the same with the default editor
501
- (`cursor`). See [docs/click-to-component.md](docs/click-to-component.md).
505
+ (`cursor`), and `npx react-x11 dev` turns it on by default. See [docs/click-to-component.md](docs/click-to-component.md).
502
506
 
503
507
  ## TypeScript
504
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.49.0",
3
+ "version": "2.51.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -112,7 +112,7 @@
112
112
  "yoga-layout": "^3.2.1"
113
113
  },
114
114
  "optionalDependencies": {
115
- "@windowkit/appkit": "^0.29.0",
115
+ "@windowkit/appkit": "^0.30.0",
116
116
  "@windowkit/wayland": "^3.1.1",
117
117
  "@windowkit/win32": "^0.0.9",
118
118
  "dbus-native": "^0.16.0",
@@ -121,7 +121,7 @@
121
121
  "peerDependencies": {
122
122
  "@babel/core": "^8.0.0",
123
123
  "@babel/plugin-transform-react-jsx": "^8.0.0",
124
- "hot-module-replacement": "^4.0.1",
124
+ "hot-module-replacement": "^4.0.2",
125
125
  "react": "^19.3.0",
126
126
  "react-refresh": "^0.19.0"
127
127
  },
@@ -153,7 +153,7 @@
153
153
  "eslint": "^9.32.0",
154
154
  "eslint-plugin-react": "^7.37.5",
155
155
  "globals": "^17.8.0",
156
- "hot-module-replacement": "^4.0.1",
156
+ "hot-module-replacement": "^4.0.2",
157
157
  "katex": "^0.19.0",
158
158
  "prettier": "^3.6.0",
159
159
  "react": "^19.3.0",
@@ -10,40 +10,9 @@
10
10
  // loader both enable) has already rewritten that Error's stack to point at
11
11
  // original source — we only have to parse the stack text and skip the
12
12
  // frames inside React/the reconciler itself.
13
- import { spawn } from 'node:child_process';
14
- import { dirname, sep } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
13
  import { setClickToComponentHandler } from './events.js';
17
14
  import { selectInDevTools } from './DevToolsIntegration.js';
18
-
19
- const STACK_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
20
-
21
- // The frames to walk past before the JSX call site itself: React captures
22
- // the Error inside `jsxDEV`/`createElement`, and an element created by this
23
- // renderer (or by node internals) is never what a click means. `SELF_DIR` is
24
- // whichever copy of react-x11 is running — the repo's own `src/` for the
25
- // examples and tests, an installed `node_modules/react-x11/src` for an app.
26
- //
27
- // Everything *else* in `node_modules` is deliberately not skipped here: a
28
- // frame inside an installed package is a real call site, it is just not one
29
- // in the application's source, and telling the two apart is what lets
30
- // `resolveOwnedLocation` climb to the owner that is. Skipping every
31
- // `node_modules` frame instead would walk straight past the library
32
- // component and land on whatever application frame happens to be deeper in
33
- // the render stack — usually the `render()` call at startup, which looks
34
- // like an answer and isn't.
35
- const REACT_RUNTIME =
36
- /[\\/]node_modules[\\/](react|react-dom|react-reconciler|scheduler)[\\/]/;
37
- const SELF_DIR = dirname(fileURLToPath(import.meta.url)) + sep;
38
-
39
- function isInternalFrame(file) {
40
- return (
41
- file.startsWith('node:') ||
42
- file === '<anonymous>' ||
43
- file.startsWith(SELF_DIR) ||
44
- REACT_RUNTIME.test(file)
45
- );
46
- }
15
+ import { openInEditor, stackFrames } from './editor.js';
47
16
 
48
17
  /** The first stack frame outside React/the reconciler/node internals — the
49
18
  * JSX call site for whatever element this Error was captured at, wherever it
@@ -52,25 +21,16 @@ function isInternalFrame(file) {
52
21
  * Exported because `react-x11/test`'s `sourceOf` answers the same question
53
22
  * for a test that a click answers for an editor. */
54
23
  export function resolveLocation(debugStack) {
55
- const stack = debugStack?.stack;
56
- if (!stack) return null;
57
- for (const line of stack.split('\n').slice(1)) {
58
- const match = line.match(STACK_FRAME);
59
- if (!match) continue;
60
- const [, functionName, rawFile, lineStr, columnStr] = match;
61
- const file = rawFile.startsWith('file://')
62
- ? fileURLToPath(rawFile)
63
- : rawFile;
64
- if (isInternalFrame(file)) continue;
65
- return {
66
- functionName,
67
- file,
68
- line: Number(lineStr),
69
- column: Number(columnStr),
70
- installed: file.includes(`${sep}node_modules${sep}`),
71
- };
72
- }
73
- return null;
24
+ const frame = stackFrames(debugStack?.stack).find((f) => !f.internal);
25
+ if (!frame) return null;
26
+ const { functionName, file, line, column, installed } = frame;
27
+ return {
28
+ functionName: functionName ?? undefined,
29
+ file,
30
+ line,
31
+ column,
32
+ installed,
33
+ };
74
34
  }
75
35
 
76
36
  /** The nearest source location a click can mean: the clicked element's own
@@ -132,87 +92,6 @@ function logOwnerChain(fiber) {
132
92
  }
133
93
  }
134
94
 
135
- // GUI editors: navigate their registered URI scheme (same idea as
136
- // https://github.com/sidorares/show-component, minus the browser) instead of
137
- // spawning their CLI shim. The CLI wrapper (e.g. `cursor -g ...`) round-trips
138
- // through a shell script that re-detects and re-execs the real app, which is
139
- // visibly slower than the OS handing the URL straight to the already-running
140
- // instance via Launch Services (macOS) / xdg-open (Linux).
141
- const EDITOR_SCHEMES = {
142
- cursor: 'cursor',
143
- code: 'vscode',
144
- vscode: 'vscode',
145
- 'code-insiders': 'vscode-insiders',
146
- 'vscode-insiders': 'vscode-insiders',
147
- windsurf: 'windsurf',
148
- };
149
-
150
- // Terminal editors have no URI scheme to navigate — spawn them directly.
151
- const EDITOR_CLI = {
152
- vim: (file, line, column) => [
153
- 'vim',
154
- [`+call cursor(${line},${column})`, file],
155
- ],
156
- nvim: (file, line, column) => [
157
- 'nvim',
158
- [`+call cursor(${line},${column})`, file],
159
- ],
160
- };
161
-
162
- const OPEN_URI_COMMAND = process.platform === 'darwin' ? 'open' : 'xdg-open';
163
-
164
- function editorUri(scheme, file, line, column) {
165
- return `${scheme}://file${encodeURI(file)}:${line}:${column}`;
166
- }
167
-
168
- function openInEditor({ file, line, column }) {
169
- const name = process.env.REACT_X11_EDITOR || 'cursor';
170
- let bin, args;
171
- if (EDITOR_CLI[name]) {
172
- [bin, args] = EDITOR_CLI[name](file, line, column);
173
- } else {
174
- // an unrecognized name is assumed to be a URI scheme itself, so any
175
- // editor that registers one works without a matching entry above
176
- const scheme = EDITOR_SCHEMES[name] || name;
177
- bin = OPEN_URI_COMMAND;
178
- args = [editorUri(scheme, file, line, column)];
179
- }
180
- // stderr is kept (rather than the whole stdio ignored) for one reason:
181
- // `open`/`xdg-open` exit non-zero and print *there* when nothing claims
182
- // the scheme — an editor that isn't installed, or one whose URL handler
183
- // was never registered. Swallowing that is what makes click-to-component
184
- // look like it only logs: the location resolves, the console line prints,
185
- // and the editor silently never opens.
186
- const child = spawn(bin, args, {
187
- detached: true,
188
- stdio: ['ignore', 'ignore', 'pipe'],
189
- });
190
- let stderr = '';
191
- // ...and unref'd, so keeping it does not keep the process alive: a pipe is
192
- // a ref'd handle, and an editor that outlives the app it was launched from
193
- // is the normal case, not a reason to hold the event loop open.
194
- child.stderr.unref();
195
- child.stderr.on('data', (chunk) => {
196
- stderr += chunk;
197
- });
198
- child.on('error', (err) => {
199
- console.warn(
200
- `react-x11: click-to-component could not launch "${bin}" (${err.code ?? err.message}). ` +
201
- 'Set REACT_X11_EDITOR to your editor CLI (cursor, code, code-insiders, windsurf, vim, nvim).',
202
- );
203
- });
204
- child.on('exit', (code) => {
205
- if (!code) return;
206
- console.warn(
207
- `react-x11: click-to-component — \`${bin} ${args.join(' ')}\` exited ${code}. ` +
208
- (stderr.trim() ? `${stderr.trim()} ` : '') +
209
- `Set REACT_X11_EDITOR to the editor you actually use (currently "${name}"; ` +
210
- 'cursor, code, code-insiders, windsurf, vim, nvim, or any registered URI scheme).',
211
- );
212
- });
213
- child.unref();
214
- }
215
-
216
95
  /** Briefly tint the clicked node so there is visual confirmation of what was
217
96
  * resolved, reusing the same overlay the DevTools hover highlight paints. */
218
97
  function flashHighlight(node) {
@@ -1,7 +1,8 @@
1
1
  // Opt-in React DevTools bridge. Enabled by setting REACT_X11_DEVTOOLS=1 in
2
- // the environment; requires the `react-devtools-core` and `ws` dev
3
- // dependencies. Start the standalone UI first (`npx react-devtools`), then
4
- // run the app: REACT_X11_DEVTOOLS=1 npm run examples:dashboard
2
+ // the environment, and on by default under `react-x11 dev`; requires the
3
+ // `react-devtools-core` dev dependency. Run the standalone UI
4
+ // (`npx react-devtools`) and the app, in either order:
5
+ // REACT_X11_DEVTOOLS=1 npm run examples:dashboard
5
6
  //
6
7
  // Most of what DevTools can do costs this file nothing: the component tree,
7
8
  // props/state/hooks, and every edit the frontend makes go through the
@@ -12,12 +13,14 @@
12
13
  // Each of those is an agent listener or an option below.
13
14
  import { spawn } from 'node:child_process';
14
15
  import fs from 'node:fs';
16
+ import { createRequire } from 'node:module';
15
17
  import os from 'node:os';
16
18
  import path from 'node:path';
17
19
  import { setInspectHandler } from './events.js';
18
20
  import { flattenStyle, isStyleProp, STYLE_PROP_NAMES } from './styles.js';
19
21
 
20
22
  let api = null;
23
+ let prepared = null;
21
24
  let connected = false;
22
25
 
23
26
  // ---------------------------------------------------------------------------
@@ -107,9 +110,16 @@ export function createStorage(persist) {
107
110
  /**
108
111
  * Install the DevTools global hook. Must run before the first React commit
109
112
  * so mounted roots are observed — Reconciler.js awaits this at module load
110
- * when REACT_X11_DEVTOOLS is set.
113
+ * when REACT_X11_DEVTOOLS is set — and before React Refresh patches the
114
+ * hook, which `react-x11 dev` calls it early for: the backend installs no
115
+ * hook where one exists, and Refresh's is a stub. Once per process.
111
116
  */
112
- export async function prepare() {
117
+ export function prepare() {
118
+ prepared ??= install();
119
+ return prepared;
120
+ }
121
+
122
+ async function install() {
113
123
  try {
114
124
  // Each hook's source location is read off a stack captured at the hook
115
125
  // dispatch, and V8 keeps ten frames — fewer than a loader leaves
@@ -122,9 +132,6 @@ export async function prepare() {
122
132
  // react-devtools-core's backend bundle expects browser-ish globals
123
133
  global.self ??= global;
124
134
  global.window ??= global;
125
- if (!global.WebSocket) {
126
- global.WebSocket = (await import('ws')).default;
127
- }
128
135
  // The update-tracing overlay schedules its redraws on animation
129
136
  // frames, which Node does not have; without these the first traced
130
137
  // commit throws inside the backend. Unref'd, because an overlay must
@@ -162,6 +169,7 @@ export async function prepare() {
162
169
  const mod = await import('react-devtools-core');
163
170
  // v7 exposes the API on the default export under node ESM interop
164
171
  api = mod.default?.initialize ? mod.default : mod;
172
+ HostSocket = hostSocketClass();
165
173
  // The hook settings the user last chose in the DevTools UI — console
166
174
  // patching and friends. `initialize()` takes them, `onSettingsUpdated`
167
175
  // (below) records them, so a setting survives a restart.
@@ -170,7 +178,7 @@ export async function prepare() {
170
178
  api = null;
171
179
  console.warn(
172
180
  'react-x11: REACT_X11_DEVTOOLS is set but devtools could not be loaded. ' +
173
- 'Install react-devtools-core and ws. Original error: ' +
181
+ 'Install react-devtools-core. Original error: ' +
174
182
  err.message,
175
183
  );
176
184
  }
@@ -254,6 +262,80 @@ export function reloadAndProfile(recordChangeDescriptions, recordTimeline) {
254
262
  // Connect
255
263
  // ---------------------------------------------------------------------------
256
264
 
265
+ // The backend keeps trying to connect for as long as the process lives — a
266
+ // timer every two seconds while no UI is listening, and an open socket while
267
+ // one is — and either is enough to keep a Node process up after the app's
268
+ // last window closed. Under `react-x11 dev`, where it is on by default, that
269
+ // was every session hanging on close. Neither is reachable from the options:
270
+ // so the backend's own code runs in a scope where the timers it starts are
271
+ // unref'd and the sockets it opens are, too. The scope is entered where the
272
+ // backend is — our connect call, its socket's close and error handlers that
273
+ // schedule the retry, and the timers it set in the scope — and swaps the
274
+ // globals only for that synchronous stretch, so the app's own `setTimeout`
275
+ // and `WebSocket` are never the ones changed.
276
+ let HostSocket = null;
277
+
278
+ function inBackend(fn) {
279
+ const { setTimeout: appSetTimeout, WebSocket: appWebSocket } = globalThis;
280
+ globalThis.setTimeout = (callback, ...rest) => {
281
+ const timer = appSetTimeout(
282
+ typeof callback === 'function'
283
+ ? (...args) => inBackend(() => callback(...args))
284
+ : callback,
285
+ ...rest,
286
+ );
287
+ timer?.unref?.();
288
+ return timer;
289
+ };
290
+ if (HostSocket) globalThis.WebSocket = HostSocket;
291
+ try {
292
+ return fn();
293
+ } finally {
294
+ globalThis.setTimeout = appSetTimeout;
295
+ globalThis.WebSocket = appWebSocket;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The backend's WebSocket: `ws`, from react-devtools-core's own
301
+ * dependencies, with its socket unref'd once open and its close and error
302
+ * handlers run in the backend's scope. Falls back to the platform's
303
+ * WebSocket, which works and holds the process open.
304
+ */
305
+ function hostSocketClass() {
306
+ let WS;
307
+ try {
308
+ WS = createRequire(import.meta.resolve('react-devtools-core'))('ws');
309
+ } catch {
310
+ return globalThis.WebSocket ?? null;
311
+ }
312
+ class Socket extends WS {
313
+ constructor(...args) {
314
+ super(...args);
315
+ this.on('open', () => this._socket?.unref?.());
316
+ }
317
+ }
318
+ for (const name of ['onclose', 'onerror']) {
319
+ const base = Object.getOwnPropertyDescriptor(WS.prototype, name);
320
+ if (!base?.set) continue;
321
+ Object.defineProperty(Socket.prototype, name, {
322
+ configurable: true,
323
+ get() {
324
+ return base.get.call(this);
325
+ },
326
+ set(handler) {
327
+ base.set.call(
328
+ this,
329
+ typeof handler === 'function'
330
+ ? (...args) => inBackend(() => handler(...args))
331
+ : handler,
332
+ );
333
+ },
334
+ });
335
+ }
336
+ return Socket;
337
+ }
338
+
257
339
  /** Connect the backend to the standalone DevTools app and register the
258
340
  * renderer. Called by Reconciler.js right after the renderer is created. */
259
341
  export function connect(renderer) {
@@ -261,39 +343,41 @@ export function connect(renderer) {
261
343
  connected = true;
262
344
  const profiling = readProfilingOnStart();
263
345
 
264
- api.connectToDevTools({
265
- isAppActive: () => true,
266
- host: process.env.REACT_X11_DEVTOOLS_HOST || 'localhost',
267
- port: Number(process.env.REACT_X11_DEVTOOLS_PORT) || 8097,
268
- // The style editor: `style` is a react-x11 style prop (an object, or a
269
- // nested array of them), which is the shape DevTools' own resolver
270
- // expects to be handed flat. `validAttributes` is what it offers to
271
- // add to an element.
272
- resolveRNStyle: resolveStyle,
273
- nativeStyleEditorValidAttributes: STYLE_PROP_NAMES,
274
- // Hook settings (console patching and so on) are the user's, not the
275
- // run's: keep them where the next run will find them.
276
- onSettingsUpdated: (settings) => {
277
- try {
278
- global.localStorage?.setItem(
279
- HOOK_SETTINGS_KEY,
280
- JSON.stringify(settings),
281
- );
282
- } catch {
283
- // an unwritable store is not worth a warning per keystroke
284
- }
285
- },
286
- // The backend's own check for this asks whether synchronous XHR works,
287
- // which is a browser question — the honest answer for a node process
288
- // that can re-exec itself is yes.
289
- isReloadAndProfileSupported: true,
290
- isProfiling: profiling != null,
291
- onReloadAndProfile: reloadAndProfile,
292
- onReloadAndProfileFlagsReset: () => {
293
- delete process.env.REACT_X11_DEVTOOLS_PROFILING;
294
- delete process.env.REACT_X11_DEVTOOLS_SESSION;
295
- },
296
- });
346
+ inBackend(() =>
347
+ api.connectToDevTools({
348
+ isAppActive: () => true,
349
+ host: process.env.REACT_X11_DEVTOOLS_HOST || 'localhost',
350
+ port: Number(process.env.REACT_X11_DEVTOOLS_PORT) || 8097,
351
+ // The style editor: `style` is a react-x11 style prop (an object, or a
352
+ // nested array of them), which is the shape DevTools' own resolver
353
+ // expects to be handed flat. `validAttributes` is what it offers to
354
+ // add to an element.
355
+ resolveRNStyle: resolveStyle,
356
+ nativeStyleEditorValidAttributes: STYLE_PROP_NAMES,
357
+ // Hook settings (console patching and so on) are the user's, not the
358
+ // run's: keep them where the next run will find them.
359
+ onSettingsUpdated: (settings) => {
360
+ try {
361
+ global.localStorage?.setItem(
362
+ HOOK_SETTINGS_KEY,
363
+ JSON.stringify(settings),
364
+ );
365
+ } catch {
366
+ // an unwritable store is not worth a warning per keystroke
367
+ }
368
+ },
369
+ // The backend's own check for this asks whether synchronous XHR works,
370
+ // which is a browser question — the honest answer for a node process
371
+ // that can re-exec itself is yes.
372
+ isReloadAndProfileSupported: true,
373
+ isProfiling: profiling != null,
374
+ onReloadAndProfile: reloadAndProfile,
375
+ onReloadAndProfileFlagsReset: () => {
376
+ delete process.env.REACT_X11_DEVTOOLS_PROFILING;
377
+ delete process.env.REACT_X11_DEVTOOLS_SESSION;
378
+ },
379
+ }),
380
+ );
297
381
 
298
382
  // No argument: react-reconciler 0.33 takes none, and the object this used
299
383
  // to pass was discarded in silence. The renderer's name and version now
package/src/Reconciler.js CHANGED
@@ -592,16 +592,15 @@ let integrationsSettled = false;
592
592
  function loadIntegrations() {
593
593
  if (integrationsSettled) return null;
594
594
  if (
595
- !process.env.REACT_X11_DEVTOOLS &&
596
- !process.env.REACT_X11_CLICK_TO_COMPONENT &&
597
- !process.env.REACT_X11_EDITOR &&
595
+ !envFlag('REACT_X11_DEVTOOLS') &&
596
+ !clickToComponentWanted() &&
598
597
  !process.env.REACT_X11_TRACE
599
598
  ) {
600
599
  return null;
601
600
  }
602
601
  if (integrations) return integrations;
603
602
  integrations = (async () => {
604
- if (process.env.REACT_X11_DEVTOOLS) {
603
+ if (envFlag('REACT_X11_DEVTOOLS')) {
605
604
  // Install the DevTools hook before any React commit, and register the
606
605
  // renderer with the standalone DevTools app.
607
606
  const devtools = await import('./DevToolsIntegration.js');
@@ -609,12 +608,7 @@ function loadIntegrations() {
609
608
  devtools.connect(Renderer);
610
609
  }
611
610
 
612
- if (
613
- process.env.REACT_X11_CLICK_TO_COMPONENT ||
614
- process.env.REACT_X11_EDITOR
615
- ) {
616
- // Naming an editor already means you want the feature on — no need to
617
- // also set REACT_X11_CLICK_TO_COMPONENT=1 just to pick one.
611
+ if (clickToComponentWanted()) {
618
612
  const clickToComponent = await import('./ClickToComponent.js');
619
613
  clickToComponent.install();
620
614
  }
@@ -632,6 +626,28 @@ function loadIntegrations() {
632
626
  return integrations;
633
627
  }
634
628
 
629
+ /**
630
+ * Whether to install click-to-component. Naming an editor already means you
631
+ * want the feature on — no need to also set REACT_X11_CLICK_TO_COMPONENT=1
632
+ * just to pick one — and `0` turns it off whatever else is set, which is
633
+ * the way out of `react-x11 dev`, where it is on by default and
634
+ * REACT_X11_EDITOR also picks the error window's editor.
635
+ */
636
+ function clickToComponentWanted() {
637
+ if (process.env.REACT_X11_CLICK_TO_COMPONENT === '0') return false;
638
+ return (
639
+ envFlag('REACT_X11_CLICK_TO_COMPONENT') ||
640
+ Boolean(process.env.REACT_X11_EDITOR)
641
+ );
642
+ }
643
+
644
+ /** A switch in the environment: set, and not `0` — the way out of a
645
+ * default `react-x11 dev` turns on. */
646
+ function envFlag(name) {
647
+ const value = process.env[name];
648
+ return Boolean(value) && value !== '0';
649
+ }
650
+
635
651
  /**
636
652
  * Open a connection this process owns. The wrapper is here for one reason:
637
653
  * to say what is wrong when there is no server, which is the first thing