react-x11 2.48.0 → 2.49.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
@@ -446,14 +446,15 @@ To have it manage your real session instead — including replacing
446
446
  npm run examples:tasks:hot # then edit examples/tasks.jsx while it runs
447
447
  ```
448
448
 
449
- Runs the tasks example under the supported hot-reload entry point,
450
- `react-x11/refresh` (`node --import react-x11/refresh/register`, Node ≥
451
- 22.15), with React **Fast Refresh**: saving `examples/tasks.jsx` updates
452
- the edited components in place. The connection, the mounted window, and
453
- component state — the task list, even half-typed text in the input —
454
- survive the reload (a component whose hook signature changed remounts
455
- alone). Any app adopts it the same way — no accept handlers, no loader
456
- files to copy; see
449
+ Runs the tasks example under `react-x11 dev`, with React **Fast Refresh**
450
+ (Node ≥ 22.15): saving `examples/tasks.jsx` updates the edited components
451
+ in place. The connection, the mounted window, and component state — the
452
+ task list, even half-typed text in the input — survive the reload (a
453
+ component whose hook signature changed remounts alone). Any app adopts it
454
+ the same way, with no accept handlers and no entry file to write:
455
+ `npx react-x11 dev app.jsx` mounts the file's default export, in a window
456
+ of its own while the tree renders none, and offers to install the toolchain
457
+ on its first run; see
457
458
  [docs/ecosystem/dev-tooling.md](docs/ecosystem/dev-tooling.md#react-refresh).
458
459
 
459
460
  ## React DevTools
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.48.0",
3
+ "version": "2.49.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
+ "bin": {
7
+ "react-x11": "./src/cli/index.js"
8
+ },
6
9
  "files": [
7
10
  "src"
8
11
  ],
@@ -26,7 +29,7 @@
26
29
  "examples:xeyes": "tsx examples/xeyes.jsx",
27
30
  "examples:dashboard": "tsx examples/dashboard.jsx",
28
31
  "examples:tasks": "tsx examples/tasks.jsx",
29
- "examples:tasks:hot": "node --enable-source-maps --import react-x11/refresh/register examples/tasks-hot.jsx",
32
+ "examples:tasks:hot": "node src/cli/index.js dev examples/tasks.jsx",
30
33
  "examples:menu": "tsx examples/menu.jsx",
31
34
  "examples:notify": "tsx examples/notify.jsx",
32
35
  "examples:monitor": "tsx examples/monitor.jsx",
package/src/Reconciler.js CHANGED
@@ -44,6 +44,7 @@ import { hasDropProps } from './dnd.js';
44
44
  import { AppProvider } from './appcontext.js';
45
45
  import {
46
46
  defaultRootHandlers,
47
+ NOT_IN_WINDOW,
47
48
  setErrorHandler,
48
49
  STRICT_TOKENS,
49
50
  } from './errors.js';
@@ -138,11 +139,13 @@ const ROOT_TYPES = new Set(['window', 'popup', THEME_SCOPE]);
138
139
  * `<box>` the app never wrote (#584).
139
140
  */
140
141
  function notAtRoot(type) {
141
- return new Error(
142
+ const err = new Error(
142
143
  `react-x11: <${type}> must be inside a <window> — it is at the root of ` +
143
144
  'the tree, which holds only <window> and <popup> elements. Render it ' +
144
145
  `inside one: <window><${type} …/></window>.`,
145
146
  );
147
+ err.code = NOT_IN_WINDOW;
148
+ return err;
146
149
  }
147
150
 
148
151
  const HostConfig = {
package/src/cli/dev.js ADDED
@@ -0,0 +1,503 @@
1
+ // `react-x11 dev <file>` — hot reload with nothing to set up.
2
+ //
3
+ // What `node --import react-x11/refresh/register main.jsx` leaves to the app,
4
+ // this does itself, and each piece is here because the --import route gets it
5
+ // wrong without one:
6
+ //
7
+ // - **It is the entry.** The file named on a command line is the one module
8
+ // hot-module-replacement never watches — it starts watching a module when
9
+ // another module imports it — so under --import an app in one file never
10
+ // reloads, and nothing says so. Here this module imports the file, which
11
+ // makes it an ordinary hot module: one file, and saving it reloads it.
12
+ // - **It mounts the file's default export**, so the file has no createRoot
13
+ // and no named-import rule to learn before its first save. A file that
14
+ // mounts a root of its own while it is imported runs as it is, the way the
15
+ // --import route runs it.
16
+ // - **It supplies a `<window>` when the tree has none.** Whether a tree has
17
+ // one cannot be read off the export — a component returns another
18
+ // component, or a window only once a state settles — but the renderer
19
+ // knows the moment it matters: a drawn element at the root throws
20
+ // NOT_IN_WINDOW before anything commits. So the component is mounted as it
21
+ // is, and on that error mounted again inside a window of ours, with nothing
22
+ // lost: the failed render committed nothing. Decided again on every reload,
23
+ // both ways, so the first answer does not outlive the code that gave it.
24
+ // - **It asks before it installs the toolchain.** Babel and friends are
25
+ // optional peer dependencies, 7.6 MB that an app's users should not
26
+ // download for a tool only its developers run, so `dev` finds them
27
+ // missing, names them with the versions the loader is written against, and
28
+ // installs them with the project's own package manager when the developer
29
+ // says yes. Without a terminal to ask on it prints the command instead.
30
+ // - **It runs the project's react-x11.** A second copy of the renderer is a
31
+ // second React root nobody refreshes, so a global or npx-cached
32
+ // `react-x11` hands over to the copy the file itself imports.
33
+ import { spawnSync } from 'node:child_process';
34
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
35
+ import * as nodeModule from 'node:module';
36
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
37
+ import { createInterface } from 'node:readline/promises';
38
+ import { fileURLToPath, pathToFileURL } from 'node:url';
39
+ import { THEME_SCOPE } from '../nodes/kinds.js';
40
+
41
+ const OWN_ROOT = fileURLToPath(new URL('../../', import.meta.url));
42
+ const OWN_PACKAGE = JSON.parse(
43
+ readFileSync(join(OWN_ROOT, 'package.json'), 'utf8'),
44
+ );
45
+
46
+ /**
47
+ * What the dev window is when the tree brings none. No size: a `<window>`
48
+ * without one is sized from its content, which is the honest frame for a
49
+ * component shown alone, and the floor only keeps a lone button's window big
50
+ * enough to find and grab.
51
+ */
52
+ const DEV_WINDOW = { minWidth: 320, minHeight: 200 };
53
+
54
+ /** A mistake the developer can fix from the message: printed bare. */
55
+ export class DevError extends Error {
56
+ constructor(message) {
57
+ super(message);
58
+ this.name = 'DevError';
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Run `file` with hot reload. Resolves once the app is mounted; the process
64
+ * then lives as long as the app's connection does.
65
+ *
66
+ * `options.args` is what the app sees after its file in `process.argv`, as
67
+ * under `node file.jsx …args`. `createRoot` and `log` are the seams the
68
+ * tests replace: the root is made against their mock app, and what this
69
+ * prints is what they read.
70
+ */
71
+ export async function runDev(file, options = {}) {
72
+ const {
73
+ args = [],
74
+ createRoot = null,
75
+ log = (line) => console.error(`react-x11 dev: ${line}`),
76
+ } = options;
77
+
78
+ const path = resolve(file);
79
+ const rel = (p) => relative(process.cwd(), p) || p;
80
+ if (!existsSync(path)) {
81
+ throw new DevError(`${file}: no such file.`);
82
+ }
83
+ const ext = extname(path);
84
+ if (!['.jsx', '.js', '.mjs'].includes(ext)) {
85
+ throw new DevError(
86
+ `${rel(path)}: the hot loader compiles .jsx files, and runs .js and ` +
87
+ `.mjs as Node does — ${ext || 'a file with no extension'} is ` +
88
+ 'neither. TypeScript is not supported yet; rename the file to .jsx.',
89
+ );
90
+ }
91
+ if (process.env.NODE_ENV === 'production') {
92
+ throw new DevError(
93
+ "NODE_ENV is 'production', and react-refresh ships a no-op build " +
94
+ 'for production, so nothing would reload. Unset NODE_ENV (or set ' +
95
+ "it to 'development') to run react-x11 dev.",
96
+ );
97
+ }
98
+ if (typeof nodeModule.registerHooks !== 'function') {
99
+ throw new DevError(
100
+ 'hot reload needs Node.js 22.15 or newer, for module.registerHooks — ' +
101
+ `this is Node ${process.versions.node}. The app itself runs on ` +
102
+ 'Node 20.19: `node` (or `npx tsx`) with your own entry file.',
103
+ );
104
+ }
105
+
106
+ const theirs = reactX11For(path);
107
+ if (theirs === null) {
108
+ throw new DevError(
109
+ `${rel(path)} cannot import react-x11: it is not installed in the ` +
110
+ 'project the file is in. Install it there:\n\n' +
111
+ ' npm install react-x11 react\n',
112
+ );
113
+ }
114
+ if (realpathSync(theirs) !== realpathSync(OWN_ROOT)) {
115
+ await handOver(theirs);
116
+ return null;
117
+ }
118
+
119
+ const projectDir = projectDirOf(dirname(path));
120
+ if ((await ensureToolchain({ projectDir, log })) === 'installed') {
121
+ restart();
122
+ return null;
123
+ }
124
+
125
+ // Before anything below loads: a module's source maps are read when it
126
+ // loads, and only once this is on.
127
+ process.setSourceMapsEnabled(true);
128
+ const { registerRefresh } = await import('../refresh/loader.js');
129
+ await registerRefresh();
130
+ // The runtime patches React DevTools' global hook and then hands the
131
+ // renderer to it, which has to happen before the first root commits.
132
+ const { default: Refresh } = await import('../refresh/index.js');
133
+ const { default: RefreshRuntime } = await import('react-refresh/runtime');
134
+ const { default: React } = await import('react');
135
+ const ReactX11 = await import('../index.js');
136
+ const { NOT_IN_WINDOW } = await import('../errors.js');
137
+ const { liveApps } = await import('../trace-registry.js');
138
+
139
+ // What `node file.jsx …args` would hand the app.
140
+ process.argv = [process.argv[0], path, ...args];
141
+
142
+ const appsBefore = liveApps().length;
143
+ const mod = await import(pathToFileURL(path).href);
144
+ const name = rel(path);
145
+ const compiled = ext === '.jsx';
146
+
147
+ // A module reloads in place only when everything it exports is a
148
+ // component (src/refresh/index.js, `moduleReady`); otherwise the edit goes
149
+ // up to its importer, which is this module, and stops there.
150
+ const notComponents = Object.keys(mod).filter(
151
+ (key) => !RefreshRuntime.isLikelyComponentType(mod[key]),
152
+ );
153
+ const boundary = Object.keys(mod).length > 0 && notComponents.length === 0;
154
+
155
+ if (liveApps().length > appsBefore) {
156
+ log(
157
+ `${name} mounted a root of its own, so it runs as it is: saving the ` +
158
+ `.jsx modules it imports reloads them, and ${name} itself needs a ` +
159
+ 'restart.',
160
+ );
161
+ if (compiled && boundary) {
162
+ log(
163
+ `${name} also exports only components, so saving it re-runs it — ` +
164
+ 'and its createRoot call, which opens another window. Leave the ' +
165
+ 'mounting to react-x11 dev (delete the createRoot call), or guard ' +
166
+ 'it: if (!import.meta.hot) { … }.',
167
+ );
168
+ }
169
+ return null;
170
+ }
171
+
172
+ const App = mod.default;
173
+ const componentLike =
174
+ typeof App === 'function' ||
175
+ (typeof App === 'object' && App !== null && '$$typeof' in App);
176
+ if (!componentLike) {
177
+ throw new DevError(
178
+ `${name} has no component as its default export, and mounted no root ` +
179
+ 'of its own, so there is nothing to show. Export the component:\n\n' +
180
+ ' export default function App() { … }\n',
181
+ );
182
+ }
183
+ if (!compiled) {
184
+ log(
185
+ `${name} is not compiled by the hot loader (it compiles .jsx files), ` +
186
+ 'so saving it needs a restart; the .jsx modules it imports reload.',
187
+ );
188
+ } else if (!boundary) {
189
+ const reasons = [];
190
+ if (notComponents.includes('default')) {
191
+ reasons.push(
192
+ 'its default export is not a component to React Refresh, which ' +
193
+ 'goes by a capitalized name — export default function App() { … }',
194
+ );
195
+ }
196
+ const others = notComponents.filter((key) => key !== 'default');
197
+ if (others.length > 0) {
198
+ reasons.push(
199
+ `${others.join(', ')} ${others.length === 1 ? 'is' : 'are'} not — ` +
200
+ 'move what is not a component to a module of its own',
201
+ );
202
+ }
203
+ log(
204
+ `saving ${name} will not reload it in place: a module does that only ` +
205
+ `when everything it exports is a component, and ${reasons.join('; ')}. ` +
206
+ 'Until then, saving it needs a restart.',
207
+ );
208
+ }
209
+
210
+ const title = `${basename(path)} — react-x11 dev`;
211
+ let wrapped = false;
212
+ let mixedNoted = false;
213
+ let root = null;
214
+ const tree = () => {
215
+ const app = React.createElement(App);
216
+ return wrapped
217
+ ? React.createElement('window', { ...DEV_WINDOW, title }, app)
218
+ : app;
219
+ };
220
+ const render = () => root.render(tree());
221
+ const mounted = () => (root.app._rootChildren ?? []).length > 0;
222
+
223
+ root = await (createRoot ?? ReactX11.createRoot)({
224
+ onUncaughtError(error, info) {
225
+ if (error?.code === NOT_IN_WINDOW && !wrapped) {
226
+ wrapped = true;
227
+ log(
228
+ `${name} renders no <window>, so it is shown in one of react-x11 ` +
229
+ "dev's; the first <window> it renders replaces it.",
230
+ );
231
+ setImmediate(render);
232
+ return;
233
+ }
234
+ if (info?.handler) {
235
+ // from an event handler: React was not on the stack and nothing
236
+ // unmounted (src/errors.js, reportHandlerError)
237
+ console.error(
238
+ `react-x11 dev: ${info.handler} on ${info.element} threw; ` +
239
+ 'dispatch continues.',
240
+ error,
241
+ );
242
+ return;
243
+ }
244
+ console.error(
245
+ 'react-x11 dev: uncaught error' + (info?.componentStack ?? ''),
246
+ error,
247
+ );
248
+ setImmediate(() => {
249
+ if (!mounted()) {
250
+ log(`fix it and save: ${name} is mounted again on the next reload.`);
251
+ }
252
+ });
253
+ },
254
+ });
255
+
256
+ // After a reload has re-rendered the edited components: take the dev
257
+ // window away from a tree that now brings its own. The other direction
258
+ // 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.
262
+ const settle = () => {
263
+ if (!wrapped || !mounted()) return;
264
+ const { windows, drawn } = rootContent(root.app._rootChildren[0]);
265
+ if (windows > 0 && drawn === 0) {
266
+ wrapped = false;
267
+ mixedNoted = false;
268
+ log(
269
+ `${name} renders its own <window> now, so it is mounted without ` +
270
+ "react-x11 dev's; that resets its state.",
271
+ );
272
+ render();
273
+ } else if (windows > 0 && !mixedNoted) {
274
+ mixedNoted = true;
275
+ log(
276
+ `${name} renders a <window> beside content that is in none, so ` +
277
+ "both stay in react-x11 dev's window. Put that content in a " +
278
+ '<window> of its own.',
279
+ );
280
+ }
281
+ };
282
+ Refresh.onReload(({ urls, refreshed }) => {
283
+ if (refreshed) {
284
+ log(`reloaded ${urls.map((url) => rel(fileURLToPath(url))).join(', ')}`);
285
+ }
286
+ setImmediate(settle);
287
+ });
288
+
289
+ render();
290
+ log(`${name} is running; saving it reloads in place. Ctrl+C stops it.`);
291
+ return root;
292
+ }
293
+
294
+ /**
295
+ * What the tree put inside the dev window, by the rule the root itself
296
+ * keeps: windows (popups among them), and anything drawn. A
297
+ * `<ThemeProvider>` is neither — inside a window it is a box that holds what
298
+ * it wraps — so its children are counted in its place.
299
+ */
300
+ function rootContent(node) {
301
+ let windows = 0;
302
+ let drawn = 0;
303
+ for (const child of node?.children ?? []) {
304
+ if (child.isWindow) windows++;
305
+ else if (child._reactFiber?.type === THEME_SCOPE) {
306
+ const inner = rootContent(child);
307
+ windows += inner.windows;
308
+ drawn += inner.drawn;
309
+ } else drawn++;
310
+ }
311
+ return { windows, drawn };
312
+ }
313
+
314
+ /** The react-x11 package `path` imports, or null when it imports none. */
315
+ export function reactX11For(path) {
316
+ let entry;
317
+ try {
318
+ entry = nodeModule.createRequire(path).resolve('react-x11');
319
+ } catch {
320
+ return null;
321
+ }
322
+ for (let dir = dirname(entry); dirname(dir) !== dir; dir = dirname(dir)) {
323
+ const manifest = join(dir, 'package.json');
324
+ if (!existsSync(manifest)) continue;
325
+ if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'react-x11') {
326
+ return dir;
327
+ }
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Hand the command over to the project's own copy of react-x11, which reads
334
+ * the same `process.argv`.
335
+ */
336
+ async function handOver(theirRoot) {
337
+ const pkg = JSON.parse(readFileSync(join(theirRoot, 'package.json'), 'utf8'));
338
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['react-x11'];
339
+ if (!bin) {
340
+ throw new DevError(
341
+ `this project's react-x11 is ${pkg.version} (${theirRoot}), which has ` +
342
+ `no react-x11 dev — it arrived in ${OWN_PACKAGE.version}. Update ` +
343
+ 'it:\n\n npm install react-x11@latest\n',
344
+ );
345
+ }
346
+ await import(pathToFileURL(join(theirRoot, bin)).href);
347
+ }
348
+
349
+ /** The nearest directory at or above `dir` with a package.json. */
350
+ function projectDirOf(dir) {
351
+ for (let at = dir; ; at = dirname(at)) {
352
+ if (existsSync(join(at, 'package.json'))) return at;
353
+ if (dirname(at) === at) return dir;
354
+ }
355
+ }
356
+
357
+ const LOCKFILES = [
358
+ ['pnpm-lock.yaml', 'pnpm'],
359
+ ['yarn.lock', 'yarn'],
360
+ ['bun.lock', 'bun'],
361
+ ['bun.lockb', 'bun'],
362
+ ['package-lock.json', 'npm'],
363
+ ['npm-shrinkwrap.json', 'npm'],
364
+ ];
365
+
366
+ /**
367
+ * The package manager a project is installed with: its lockfile, nearest
368
+ * first, then the one that launched this command (`npx`, `pnpm dlx`, `bunx`
369
+ * set `npm_config_user_agent`), then npm.
370
+ */
371
+ export function packageManagerFor(
372
+ dir,
373
+ userAgent = process.env.npm_config_user_agent,
374
+ ) {
375
+ for (let at = dir; ; at = dirname(at)) {
376
+ for (const [file, manager] of LOCKFILES) {
377
+ if (existsSync(join(at, file))) return manager;
378
+ }
379
+ if (dirname(at) === at) break;
380
+ }
381
+ return /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm';
382
+ }
383
+
384
+ /** The command that adds `packages` as dev dependencies, as argv. */
385
+ export function installCommand(manager, packages) {
386
+ switch (manager) {
387
+ case 'pnpm':
388
+ return ['pnpm', 'add', '--save-dev', ...packages];
389
+ case 'yarn':
390
+ return ['yarn', 'add', '--dev', ...packages];
391
+ case 'bun':
392
+ return ['bun', 'add', '--dev', ...packages];
393
+ default:
394
+ return ['npm', 'install', '--save-dev', ...packages];
395
+ }
396
+ }
397
+
398
+ /** `argv` as one line a POSIX shell (or cmd.exe) reads back the same. */
399
+ function shellLine(argv) {
400
+ const quote = (arg) =>
401
+ /^[\w@./:=-]+$/.test(arg)
402
+ ? arg
403
+ : process.platform === 'win32'
404
+ ? `"${arg}"`
405
+ : `'${arg}'`;
406
+ return argv.map(quote).join(' ');
407
+ }
408
+
409
+ /**
410
+ * Make sure the hot-reload toolchain resolves from this copy of react-x11,
411
+ * which is where the loader imports it from. `'ready'` when it does,
412
+ * `'installed'` when it was just installed (the caller restarts: a package
413
+ * manager may have moved react-x11 itself to satisfy the new peers), and a
414
+ * DevError carrying the command when it is missing and was not installed.
415
+ *
416
+ * Every way out of this is a seam for the tests: `resolveSpecifier`,
417
+ * `interactive`, `ask` and `run`.
418
+ */
419
+ export async function ensureToolchain({
420
+ projectDir,
421
+ log,
422
+ resolveSpecifier = (specifier) => import.meta.resolve(specifier),
423
+ interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY),
424
+ ask = askOnTerminal,
425
+ run = (argv, cwd) =>
426
+ spawnSync(argv[0], argv.slice(1), {
427
+ cwd,
428
+ stdio: 'inherit',
429
+ // npm and friends are .cmd scripts there, which only a shell runs
430
+ shell: process.platform === 'win32',
431
+ }).status,
432
+ }) {
433
+ const { TOOLCHAIN } = await import('../refresh/loader.js');
434
+ const missing = TOOLCHAIN.filter(({ specifier }) => {
435
+ try {
436
+ resolveSpecifier(specifier);
437
+ return false;
438
+ } catch {
439
+ return true;
440
+ }
441
+ });
442
+ if (missing.length === 0) return 'ready';
443
+
444
+ // The versions the loader is written against, from react-x11's own
445
+ // peerDependencies: an unpinned install takes whatever major is newest.
446
+ const packages = missing.map(
447
+ ({ name }) => `${name}@${OWN_PACKAGE.peerDependencies[name]}`,
448
+ );
449
+ const argv = installCommand(packageManagerFor(projectDir), packages);
450
+ const line = shellLine(argv);
451
+ const what =
452
+ `hot reload needs ${missing.length === 1 ? 'a package' : 'packages'} ` +
453
+ `this project does not have: ${missing.map((m) => m.name).join(', ')}. ` +
454
+ 'They are optional peer dependencies of react-x11, so an app that never ' +
455
+ 'hot-reloads does not ship them to its users.';
456
+ if (!interactive) {
457
+ throw new DevError(
458
+ `${what} Install them as dev dependencies:\n\n ${line}\n`,
459
+ );
460
+ }
461
+ log(what);
462
+ const answer = (await ask(`Run \`${line}\` in ${projectDir}? [Y/n] `))
463
+ .trim()
464
+ .toLowerCase();
465
+ if (answer !== '' && answer !== 'y' && answer !== 'yes') {
466
+ throw new DevError(
467
+ `not installed. To install them yourself:\n\n ${line}\n`,
468
+ );
469
+ }
470
+ const status = run(argv, projectDir);
471
+ if (status !== 0) {
472
+ throw new DevError(
473
+ `\`${line}\` failed${status == null ? '' : ` (exit ${status})`}; its ` +
474
+ 'output is above.',
475
+ );
476
+ }
477
+ return 'installed';
478
+ }
479
+
480
+ async function askOnTerminal(question) {
481
+ const terminal = createInterface({
482
+ input: process.stdin,
483
+ output: process.stderr,
484
+ });
485
+ try {
486
+ return await terminal.question(question);
487
+ } finally {
488
+ terminal.close();
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Start this command again, once the toolchain is installed: the new process
494
+ * resolves the packages, and react-x11 itself, from scratch.
495
+ */
496
+ function restart() {
497
+ const { status, signal } = spawnSync(
498
+ process.execPath,
499
+ [...process.execArgv, ...process.argv.slice(1)],
500
+ { stdio: 'inherit' },
501
+ );
502
+ process.exitCode = status ?? (signal ? 1 : 0);
503
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // react-x11's command line, the `bin` of the package. One command so far:
3
+ //
4
+ // react-x11 dev <file> [args…] hot reload with nothing to set up (./dev.js)
5
+ //
6
+ // Arguments before the file belong to the command, and everything after it
7
+ // to the app, which sees them in process.argv as `node <file> …args` would
8
+ // have handed them over.
9
+ import { readFileSync } from 'node:fs';
10
+ import { DevError, runDev } from './dev.js';
11
+
12
+ const USAGE = `Usage: react-x11 dev <file> [args…]
13
+
14
+ Commands:
15
+ dev <file> Run <file> with hot reload: saving it, or a .jsx module it
16
+ imports, updates the running app in place. Its default
17
+ export is mounted, inside a window of its own when it
18
+ renders none; a file that mounts its own root runs as it is.
19
+ Arguments after the file are the app's.
20
+
21
+ Options:
22
+ -h, --help Show this help.
23
+ -v, --version Print react-x11's version.
24
+ `;
25
+
26
+ function version() {
27
+ const manifest = new URL('../../package.json', import.meta.url);
28
+ return JSON.parse(readFileSync(manifest, 'utf8')).version;
29
+ }
30
+
31
+ /** An exit code to finish with, or undefined while the app runs. */
32
+ async function main([command, ...rest]) {
33
+ if (command === '-h' || command === '--help' || command === 'help') {
34
+ process.stdout.write(USAGE);
35
+ return 0;
36
+ }
37
+ if (command === '-v' || command === '--version') {
38
+ process.stdout.write(`${version()}\n`);
39
+ return 0;
40
+ }
41
+ if (command === undefined) {
42
+ process.stderr.write(USAGE);
43
+ return 2;
44
+ }
45
+ if (command !== 'dev') {
46
+ process.stderr.write(`react-x11: no command "${command}".\n\n${USAGE}`);
47
+ return 2;
48
+ }
49
+ const at = rest.findIndex((arg) => !arg.startsWith('-'));
50
+ const own = at === -1 ? rest : rest.slice(0, at);
51
+ if (own.includes('-h') || own.includes('--help')) {
52
+ process.stdout.write(USAGE);
53
+ return 0;
54
+ }
55
+ if (own.length > 0) {
56
+ process.stderr.write(
57
+ `react-x11 dev: no option ${own[0]} — options after the file are ` +
58
+ `the app's.\n\n${USAGE}`,
59
+ );
60
+ return 2;
61
+ }
62
+ if (at === -1) {
63
+ process.stderr.write(`react-x11 dev: which file?\n\n${USAGE}`);
64
+ return 2;
65
+ }
66
+ await runDev(rest[at], { args: rest.slice(at + 1) });
67
+ return undefined;
68
+ }
69
+
70
+ try {
71
+ const code = await main(process.argv.slice(2));
72
+ if (code !== undefined) process.exitCode = code;
73
+ } catch (err) {
74
+ // A DevError is a sentence about the setup, and its stack is ours; any
75
+ // other throw came out of the app's own module, and its stack is the
76
+ // useful part.
77
+ console.error(
78
+ err instanceof DevError ? `react-x11 dev: ${err.message}` : err,
79
+ );
80
+ process.exitCode = 1;
81
+ }
package/src/cocoa/app.js CHANGED
@@ -178,7 +178,17 @@ export class CocoaApp {
178
178
  // policy goes first, through `initApp`'s option (bridge >= 0.5), and
179
179
  // only when the root asked for one: a bridge without the option, or a
180
180
  // fake, is left alone.
181
- const policy = options.cocoa?.activationPolicy;
181
+ //
182
+ // A `<Frame>` pane asks for one without being told: 'prohibited'. Its
183
+ // pixels are the host's to show, it makes no NSWindow (`createWindow`)
184
+ // and it runs no NSApplication, so nothing of it may be an app the user
185
+ // sees — and launched Regular, the bridge's default, every pane was a
186
+ // `node` tile of its own in the Dock and in ⌘-Tab, one per tab of a
187
+ // browser. Prohibited rather than accessory because a pane must not be
188
+ // activated either: the host window has the focus and forwards it.
189
+ const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
190
+ const policy =
191
+ options.cocoa?.activationPolicy ?? (pane ? 'prohibited' : undefined);
182
192
  // Under the launcher (src/cocoa/main.js) the app launched before this
183
193
  // code ran, so the policy is a switch after the fact, and a Regular
184
194
  // launch has already registered its Dock tile. The bridge publishes
@@ -257,7 +267,6 @@ export class CocoaApp {
257
267
  // here then (src/cocoa/paneplayer.js); one that does not leaves a pane
258
268
  // refusing `src`, its poster shown and `onError` told.
259
269
  this._players = new Map();
260
- const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
261
270
  if (canPlay(native) && !pane) {
262
271
  this.createPlayer = (src, options) => new CocoaPlayer(this, src, options);
263
272
  }
@@ -388,7 +397,7 @@ export class CocoaApp {
388
397
 
389
398
  // Frame-pane mode: this process renders a pane whose pixels a host
390
399
  // composites (REACT_X11_FRAME is what the Frame host sets on the fork).
391
- this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
400
+ this._paneMode = pane;
392
401
  this._paneSend = null;
393
402
  // the pane's own window, the one the host lays out and forwards to
394
403
  this._paneWindow = null;
package/src/errors.js CHANGED
@@ -6,6 +6,14 @@
6
6
  // `data` handler and takes the process with it, or wedges the frame loop,
7
7
  // which is worse: it looks like a hang rather than a crash.
8
8
 
9
+ /**
10
+ * The `code` of the error a drawn element at the root of the tree throws
11
+ * (Reconciler.js, `notAtRoot`). A code because a tool acts on it: `react-x11
12
+ * dev` mounts a component that renders no `<window>` inside one of its own,
13
+ * and the message is prose that is free to change.
14
+ */
15
+ export const NOT_IN_WINDOW = 'NOT_IN_WINDOW';
16
+
9
17
  // container -> the root's onUncaughtError, when it set one
10
18
  const perContainer = new WeakMap();
11
19
 
@@ -31,11 +31,22 @@ import { markHotReloadSession } from '../registry.js';
31
31
  const RUNTIME_URL = new URL('./index.js', import.meta.url).href;
32
32
  const SRC_DIR = fileURLToPath(new URL('../', import.meta.url));
33
33
 
34
- const TOOLCHAIN = [
35
- '@babel/core',
36
- '@babel/plugin-transform-react-jsx',
37
- 'react-refresh',
38
- 'hot-module-replacement',
34
+ /**
35
+ * The hot-reload toolchain: each specifier this loader imports, and the
36
+ * package it comes from. Optional peer dependencies, so `react-x11 dev`
37
+ * checks the same list before it starts (src/cli/dev.js).
38
+ */
39
+ export const TOOLCHAIN = [
40
+ { specifier: '@babel/core', name: '@babel/core' },
41
+ {
42
+ specifier: '@babel/plugin-transform-react-jsx',
43
+ name: '@babel/plugin-transform-react-jsx',
44
+ },
45
+ { specifier: 'react-refresh/babel', name: 'react-refresh' },
46
+ {
47
+ specifier: 'hot-module-replacement/register',
48
+ name: 'hot-module-replacement',
49
+ },
39
50
  ];
40
51
 
41
52
  async function loadToolchain() {
@@ -54,13 +65,22 @@ async function loadToolchain() {
54
65
  '@babel/plugin-transform-react-jsx',
55
66
  );
56
67
  const refreshTransform = await load('react-refresh/babel', 'react-refresh');
68
+ // Resolved rather than imported: importing it registers its hooks, which
69
+ // has to wait until ours are in. Checked here all the same, so a missing
70
+ // one is this message and not a bare ERR_MODULE_NOT_FOUND after the
71
+ // babel hooks already went in.
72
+ try {
73
+ import.meta.resolve('hot-module-replacement/register');
74
+ } catch {
75
+ missing.push('hot-module-replacement');
76
+ }
57
77
  if (missing.length > 0) {
58
78
  throw new Error(
59
79
  `react-x11/refresh: the hot-reload toolchain is not installed ` +
60
80
  `(missing: ${missing.join(', ')}). It is a set of optional peer ` +
61
81
  `dependencies — an app that never hot-reloads does not carry them. ` +
62
82
  `Install them where the dev loop runs:\n\n` +
63
- ` npm install --save-dev ${TOOLCHAIN.join(' ')}\n`,
83
+ ` npm install --save-dev ${TOOLCHAIN.map((t) => t.name).join(' ')}\n`,
64
84
  );
65
85
  }
66
86
  return {
@@ -1,11 +1,14 @@
1
1
  // react-x11/refresh/register — hot reload with the defaults, for --import:
2
2
  //
3
- // node --enable-source-maps --import react-x11/refresh/register app.jsx
3
+ // node --enable-source-maps --import react-x11/refresh/register main.jsx
4
4
  //
5
- // The app's entry needs no changes: the loader injects the runtime wiring
6
- // into every hot module, edits to components apply in place with hook
7
- // state intact, and `onReload` (from react-x11/refresh) is there when a
8
- // tool wants to observe the reloads. A tool that needs the seams —
5
+ // The app writes no wiring of its own: the loader injects the runtime into
6
+ // every hot module, edits to components apply in place with hook state
7
+ // intact, and `onReload` (from react-x11/refresh) is there when a tool wants
8
+ // to observe the reloads. What the app does owe is a split: main.jsx is
9
+ // never watched — hot-module-replacement watches a module when another
10
+ // imports it — so the components it mounts live in modules it imports, and
11
+ // an app in one file never reloads. A tool that needs the seams —
9
12
  // extensions, ignore, an injected prelude — writes its own two-line
10
13
  // --import module calling registerRefresh(options) instead.
11
14
  import { registerRefresh } from './loader.js';