react-x11 2.49.0 → 2.50.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.49.0",
3
+ "version": "2.50.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -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) {
package/src/cli/dev.js CHANGED
@@ -125,7 +125,7 @@ export async function runDev(file, options = {}) {
125
125
  // Before anything below loads: a module's source maps are read when it
126
126
  // loads, and only once this is on.
127
127
  process.setSourceMapsEnabled(true);
128
- const { registerRefresh } = await import('../refresh/loader.js');
128
+ const { onTransform, registerRefresh } = await import('../refresh/loader.js');
129
129
  await registerRefresh();
130
130
  // The runtime patches React DevTools' global hook and then hands the
131
131
  // renderer to it, which has to happen before the first root commits.
@@ -135,6 +135,7 @@ export async function runDev(file, options = {}) {
135
135
  const ReactX11 = await import('../index.js');
136
136
  const { NOT_IN_WINDOW } = await import('../errors.js');
137
137
  const { liveApps } = await import('../trace-registry.js');
138
+ const { ErrorWindow, describeError } = await import('./errorwindow.js');
138
139
 
139
140
  // What `node file.jsx …args` would hand the app.
140
141
  process.argv = [process.argv[0], path, ...args];
@@ -210,19 +211,55 @@ export async function runDev(file, options = {}) {
210
211
  const title = `${basename(path)} — react-x11 dev`;
211
212
  let wrapped = false;
212
213
  let mixedNoted = false;
214
+ // What the error window shows: a render error took the app's tree down,
215
+ // and the app stays down until the next reload tries it again; a compile
216
+ // error is a save that did not compile, beside an app still running the
217
+ // module's last version. Both at once shows the compile error, which is
218
+ // the newer news.
219
+ let renderFailure = null;
220
+ let compileFailure = null;
221
+ // Set when a reload starts while a render error is showing: that reload is
222
+ // the one to try the app again on. Not the reload that threw — react-refresh
223
+ // calls the reload listeners in the same batch as the render that failed,
224
+ // and retrying there would only throw it a second time.
225
+ let retry = false;
213
226
  let root = null;
227
+ // Two children, always in these places: an error window opening or
228
+ // closing beside the app is then a sibling coming and going, and the app's
229
+ // state is untouched by it.
214
230
  const tree = () => {
215
231
  const app = React.createElement(App);
216
- return wrapped
217
- ? React.createElement('window', { ...DEV_WINDOW, title }, app)
218
- : app;
232
+ const failure = compileFailure ?? renderFailure;
233
+ return React.createElement(
234
+ React.Fragment,
235
+ null,
236
+ renderFailure
237
+ ? null
238
+ : wrapped
239
+ ? React.createElement('window', { ...DEV_WINDOW, title }, app)
240
+ : app,
241
+ failure
242
+ ? React.createElement(ErrorWindow, {
243
+ failure,
244
+ title: `${basename(failure.filename ?? path)} — error`,
245
+ // Beside a running app it is a second window, whose closing is
246
+ // ours to answer: it goes until the next error. Standing in for
247
+ // the app it is the only one, and closing it ends the app.
248
+ onCloseRequest: renderFailure
249
+ ? undefined
250
+ : () => {
251
+ compileFailure = null;
252
+ render();
253
+ },
254
+ })
255
+ : null,
256
+ );
219
257
  };
220
258
  const render = () => root.render(tree());
221
- const mounted = () => (root.app._rootChildren ?? []).length > 0;
222
259
 
223
260
  root = await (createRoot ?? ReactX11.createRoot)({
224
261
  onUncaughtError(error, info) {
225
- if (error?.code === NOT_IN_WINDOW && !wrapped) {
262
+ if (error?.code === NOT_IN_WINDOW && !wrapped && !renderFailure) {
226
263
  wrapped = true;
227
264
  log(
228
265
  `${name} renders no <window>, so it is shown in one of react-x11 ` +
@@ -241,27 +278,65 @@ export async function runDev(file, options = {}) {
241
278
  );
242
279
  return;
243
280
  }
244
- console.error(
245
- 'react-x11 dev: uncaught error' + (info?.componentStack ?? ''),
281
+ console.error(error);
282
+ const chain = describeError({
283
+ kind: 'render',
246
284
  error,
285
+ componentStack: info?.componentStack,
286
+ }).components;
287
+ // Already showing one: this came out of the error window itself, or
288
+ // the tree failed again before a reload — the terminal has it, and a
289
+ // window answering its own failure by mounting itself again would
290
+ // never stop.
291
+ if (renderFailure) return;
292
+ log(
293
+ (chain.length > 0 ? `thrown rendering ${chain.join(' › ')}. ` : '') +
294
+ `Fix it and save: ${name} is mounted again on the next reload.`,
247
295
  );
248
- setImmediate(() => {
249
- if (!mounted()) {
250
- log(`fix it and save: ${name} is mounted again on the next reload.`);
251
- }
252
- });
296
+ renderFailure = {
297
+ kind: 'render',
298
+ error,
299
+ componentStack: info?.componentStack,
300
+ };
301
+ setImmediate(render);
253
302
  },
254
303
  });
255
304
 
256
- // After a reload has re-rendered the edited components: take the dev
305
+ // A save that does not compile: the reload library prints it and keeps the
306
+ // module's last version, so this is the only place it can be shown. Before
307
+ // the mount, the import of the file itself throws, and the bin prints it.
308
+ //
309
+ // Every reload compiles at least the module it stops at, so this is also
310
+ // where a reload is first seen.
311
+ onTransform(({ filename, error }) => {
312
+ if (error) {
313
+ compileFailure = { kind: 'compile', error, filename };
314
+ setImmediate(render);
315
+ return;
316
+ }
317
+ if (renderFailure) retry = true;
318
+ if (compileFailure?.filename === filename) {
319
+ compileFailure = null;
320
+ setImmediate(render);
321
+ }
322
+ });
323
+
324
+ // After a reload: try an app an error took down again, and take the dev
257
325
  // window away from a tree that now brings its own. The other direction
258
326
  // needs nothing here — a tree that lost its window throws NOT_IN_WINDOW,
259
- // and the handler above wraps it — and neither does a tree an error took
260
- // down: react-refresh keeps the roots that failed, and renders them again
261
- // on the next reload itself.
327
+ // and the handler above wraps it.
262
328
  const settle = () => {
263
- if (!wrapped || !mounted()) return;
264
- const { windows, drawn } = rootContent(root.app._rootChildren[0]);
329
+ if (renderFailure) {
330
+ if (!retry) return;
331
+ retry = false;
332
+ renderFailure = null;
333
+ render();
334
+ return;
335
+ }
336
+ if (!wrapped) return;
337
+ const devWindow = (root.app._rootChildren ?? [])[0];
338
+ if (!devWindow) return;
339
+ const { windows, drawn } = rootContent(devWindow);
265
340
  if (windows > 0 && drawn === 0) {
266
341
  wrapped = false;
267
342
  mixedNoted = false;