react-x11 2.47.5 → 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.47.5",
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",
@@ -109,7 +112,7 @@
109
112
  "yoga-layout": "^3.2.1"
110
113
  },
111
114
  "optionalDependencies": {
112
- "@windowkit/appkit": "^0.28.0",
115
+ "@windowkit/appkit": "^0.29.0",
113
116
  "@windowkit/wayland": "^3.1.1",
114
117
  "@windowkit/win32": "^0.0.9",
115
118
  "dbus-native": "^0.16.0",
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
@@ -71,6 +71,9 @@ const PUMP_INTERVAL_MS = 8;
71
71
  // to 100, docs/windows.md §"Resize"). `createRoot({ cocoa: { resizeWait } })`
72
72
  // sets it; 0 is no handshake at all.
73
73
  const RESIZE_WAIT_MS = 50;
74
+ // What `createRoot({ cocoa: { paneStretch } })` and
75
+ // REACT_X11_COCOA_PANE_STRETCH take (src/cocoa/panehost.js, `_stretch`).
76
+ const PANE_STRETCH = ['behind', 'never', 'always'];
74
77
  // How often the screen layout is re-read while something is watching it, in
75
78
  // ms. There is no event to wait for — the bridge keeps its `NSScreen` copy
76
79
  // current on macOS's notification and emits nothing (#617) — so
@@ -149,6 +152,24 @@ export class CocoaApp {
149
152
  );
150
153
  }
151
154
  this._resizeWait = resizeWait;
155
+ // How a `<Frame>` pane's last frame is shown while the frame of the
156
+ // size its layer was just given is on its way (src/cocoa/panehost.js,
157
+ // `_stretch`): 'behind' stretches it across once the pane falls behind
158
+ // `resizeWait`, 'never' keeps it at its size however late the pane is,
159
+ // and 'always' stretches it both ways. Apart from `resizeWait`, which
160
+ // is also how long AppKit holds the window's edge for a frame, so a
161
+ // pane that is never to be stretched would otherwise need a budget
162
+ // that held the drag back as long.
163
+ const paneStretch =
164
+ options.cocoa?.paneStretch ??
165
+ (process.env.REACT_X11_COCOA_PANE_STRETCH || 'behind');
166
+ if (!PANE_STRETCH.includes(paneStretch)) {
167
+ throw new TypeError(
168
+ 'react-x11: cocoa.paneStretch (REACT_X11_COCOA_PANE_STRETCH) is ' +
169
+ `one of ${PANE_STRETCH.join(', ')} — got ${paneStretch}.`,
170
+ );
171
+ }
172
+ this._paneStretch = paneStretch;
152
173
 
153
174
  // The activation policy has to be fixed before the app finishes
154
175
  // launching — a Regular launch registers a Dock tile, so an agent app
@@ -157,7 +178,17 @@ export class CocoaApp {
157
178
  // policy goes first, through `initApp`'s option (bridge >= 0.5), and
158
179
  // only when the root asked for one: a bridge without the option, or a
159
180
  // fake, is left alone.
160
- 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);
161
192
  // Under the launcher (src/cocoa/main.js) the app launched before this
162
193
  // code ran, so the policy is a switch after the fact, and a Regular
163
194
  // launch has already registered its Dock tile. The bridge publishes
@@ -236,7 +267,6 @@ export class CocoaApp {
236
267
  // here then (src/cocoa/paneplayer.js); one that does not leaves a pane
237
268
  // refusing `src`, its poster shown and `onError` told.
238
269
  this._players = new Map();
239
- const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
240
270
  if (canPlay(native) && !pane) {
241
271
  this.createPlayer = (src, options) => new CocoaPlayer(this, src, options);
242
272
  }
@@ -367,7 +397,7 @@ export class CocoaApp {
367
397
 
368
398
  // Frame-pane mode: this process renders a pane whose pixels a host
369
399
  // composites (REACT_X11_FRAME is what the Frame host sets on the fork).
370
- this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
400
+ this._paneMode = pane;
371
401
  this._paneSend = null;
372
402
  // the pane's own window, the one the host lays out and forwards to
373
403
  this._paneWindow = null;
package/src/cocoa/dnd.js CHANGED
@@ -34,7 +34,8 @@
34
34
  // the pasteboard, so an in-app drop still gets `e.items` by reference and
35
35
  // `e.source === 'internal'`, the contract the X11 transport keeps. Other
36
36
  // applications read the pasteboard, where a thunk is a promise the bridge
37
- // asks `provide` to keep.
37
+ // asks `provide` to keep — on the main thread. On a worker the bridge takes
38
+ // no `provide`, and a thunk resolves as the drag begins (`dragSpec`).
38
39
  //
39
40
  // ## Types
40
41
  //
@@ -223,6 +224,16 @@ export const wire = (value) =>
223
224
  * to keep. The files thunk alone resolves now, because the items cannot
224
225
  * be counted without it.
225
226
  *
227
+ * Unless the app is on a worker (`upFront`, threaded mode): AppKit asks a
228
+ * provider from inside its own callback on the main thread, which a worker
229
+ * cannot answer in time, so the bridge refuses `provide` there with a
230
+ * TypeError. That was every drag on a Mac, since an app runs on a worker
231
+ * by default. There every thunk resolves now, as the win32 backend has
232
+ * them, and this is the external promotion `_resolve` caches for: an
233
+ * in-app drop is handed the same value by reference. A value that resolves
234
+ * to nothing, or that JSON cannot carry (a cycle, a node), is left off
235
+ * the pasteboard rather than failing the drag; an in-app drop still has it.
236
+ *
226
237
  * No image, and so no slide-back. The picture of the drag is the app's own
227
238
  * `<popup dragPreview>`; what the system session carries is a blank 1×1.
228
239
  * Left at AppKit's default, a refused or cancelled drop plays
@@ -236,7 +247,7 @@ export const wire = (value) =>
236
247
  * to animate from `onDragEnd`. Not a seam: there is no system image for
237
248
  * a caller to want slid back.
238
249
  */
239
- export function dragSpec(session, native, scale) {
250
+ export function dragSpec(session, native, scale, { upFront = false } = {}) {
240
251
  const types = session.types;
241
252
  const reverse = new Map(); // uti -> react-x11 type
242
253
  const items = [];
@@ -256,7 +267,17 @@ export function dragSpec(session, native, scale) {
256
267
  if (!uti || uti in first) continue;
257
268
  reverse.set(uti, type);
258
269
  const raw = session._data[type];
259
- first[uti] = typeof raw === 'function' ? null : wire(raw);
270
+ if (!upFront) {
271
+ first[uti] = typeof raw === 'function' ? null : wire(raw);
272
+ continue;
273
+ }
274
+ const value = session._resolve(type);
275
+ if (value == null) continue;
276
+ try {
277
+ first[uti] = wire(value);
278
+ } catch {
279
+ // not something the pasteboard can carry; the in-app drop has it
280
+ }
260
281
  }
261
282
  if (Object.keys(first).length) {
262
283
  if (items.length) Object.assign(items[0], first);
@@ -269,7 +290,11 @@ export function dragSpec(session, native, scale) {
269
290
  items,
270
291
  operations: session.actions,
271
292
  slideBack: false,
272
- provide: (uti) => wire(session._resolve(reverse.get(uti) ?? uti)),
293
+ ...(upFront
294
+ ? null
295
+ : {
296
+ provide: (uti) => wire(session._resolve(reverse.get(uti) ?? uti)),
297
+ }),
273
298
  };
274
299
  }
275
300
 
@@ -91,7 +91,8 @@ class PaneLayer {
91
91
  * and a smeared strip. The budget is the window's live-resize
92
92
  * handshake's (`createRoot({ cocoa: { resizeWait } })`), how long a frame
93
93
  * of a new size may take before the old one is shown in its place: 0
94
- * stretches always, both ways, as before.
94
+ * stretches always, both ways, as before. And the app may say otherwise
95
+ * whatever the budget (`_stretch`).
95
96
  *
96
97
  * Down the layer the frame is at its size whatever this says. A width
97
98
  * moves where a page's lines break, and what comes is a page laid out
@@ -116,8 +117,9 @@ class PaneLayer {
116
117
  * decides the rest (`_answered`).
117
118
  */
118
119
  _anchoredNow() {
120
+ const stretch = this._stretch();
121
+ if (stretch !== 'behind') return stretch === 'never';
119
122
  const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
120
- if (!(budget > 0)) return false;
121
123
  // a size asked longer ago than the budget, and no frame of any since
122
124
  const oldest = this._asked[0];
123
125
  if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
@@ -126,6 +128,23 @@ class PaneLayer {
126
128
  return !this._behind;
127
129
  }
128
130
 
131
+ /**
132
+ * When the frame the layer holds is stretched to bounds that are not its
133
+ * size: `'behind'`, across, once the pane falls behind the budget
134
+ * (`_anchoredNow`); `'never'`, kept at its size and its edge carried over
135
+ * however late the pane is; `'always'`, both ways, Core Animation's own.
136
+ * The app's `cocoa.paneStretch` (REACT_X11_COCOA_PANE_STRETCH), and
137
+ * `'behind'` where it names none, or `'always'` at a budget of 0, which
138
+ * waits for no frame. An app that says `'never'` was not served by the
139
+ * budget: as long as AppKit holds the window's edge for a frame, so a
140
+ * budget a slow page never misses held every step of the drag back.
141
+ */
142
+ _stretch() {
143
+ const stretch = this.app._paneStretch ?? 'behind';
144
+ if (stretch !== 'behind') return stretch;
145
+ return (this.app._resizeWait ?? RESIZE_WAIT_MS) > 0 ? 'behind' : 'always';
146
+ }
147
+
129
148
  /**
130
149
  * The props that show the frame the layer holds in bounds that are not
131
150
  * its size, where they changed. At its size and from the top left, as a
@@ -138,7 +157,8 @@ class PaneLayer {
138
157
  * the frame is stretched, and down it is still at its size
139
158
  * (`_anchoredNow`): the whole of each row stretched to the new width,
140
159
  * cropped or with its last row carried down. Both rects are the whole
141
- * when the frame is the layer's size, and at a budget of 0.
160
+ * when the frame is the layer's size, and where it is stretched always
161
+ * (`_stretch`).
142
162
  *
143
163
  * Anchored by gravity alone, the strip the frame did not cover showed
144
164
  * whatever was under the pane, which is the `<Frame>`'s background: in
@@ -159,8 +179,7 @@ class PaneLayer {
159
179
  const rect = this._rect;
160
180
  let crop = null;
161
181
  let edge = null;
162
- const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
163
- if (frame && rect && budget > 0) {
182
+ if (frame && rect && this._stretch() !== 'always') {
164
183
  // across at its size while the pane keeps up, and down always; an
165
184
  // axis stretched is shown whole, the frame's every column across
166
185
  const across = this._anchoredNow();
@@ -520,7 +520,9 @@ export class CocoaWindow {
520
520
  if (this.destroyed) return null;
521
521
  const began = this._native.beginDrag(
522
522
  this._h,
523
- dragSpec(session, this._native, this.scale),
523
+ dragSpec(session, this._native, this.scale, {
524
+ upFront: this.app._threaded,
525
+ }),
524
526
  );
525
527
  this.app._afterInput();
526
528
  return began;
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
 
package/src/index.d.ts CHANGED
@@ -306,6 +306,11 @@ export interface RootOptions {
306
306
  * the next comes, and past it, it is stretched across to the pane's new
307
307
  * width and stays at its size down it; at 0 every one is stretched both
308
308
  * ways.
309
+ * `paneStretch` says when a `<Frame>` pane's last frame is stretched
310
+ * instead, whatever `resizeWait` is: `'behind'` (the default) as above,
311
+ * `'never'` keeps it at its size however late the pane's next frame is,
312
+ * and `'always'` stretches it both ways. `REACT_X11_COCOA_PANE_STRETCH`
313
+ * says the same from the environment.
309
314
  * `screenPoll` is how often the screen layout is re-read while a
310
315
  * {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
311
316
  * never. macOS has no event for a display plugged in, unplugged or
@@ -333,6 +338,7 @@ export interface RootOptions {
333
338
  frameInterval?: number;
334
339
  pumpInterval?: number;
335
340
  resizeWait?: number;
341
+ paneStretch?: 'behind' | 'never' | 'always';
336
342
  screenPoll?: number;
337
343
  appName?: string;
338
344
  activationPolicy?: 'regular' | 'accessory' | 'prohibited';
@@ -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';