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 +9 -8
- package/package.json +5 -2
- package/src/Reconciler.js +4 -1
- package/src/cli/dev.js +503 -0
- package/src/cli/index.js +81 -0
- package/src/cocoa/app.js +12 -3
- package/src/errors.js +8 -0
- package/src/refresh/loader.js +26 -6
- package/src/refresh/register.js +8 -5
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
|
|
450
|
-
`
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
component
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
package/src/refresh/loader.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 {
|
package/src/refresh/register.js
CHANGED
|
@@ -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
|
|
3
|
+
// node --enable-source-maps --import react-x11/refresh/register main.jsx
|
|
4
4
|
//
|
|
5
|
-
// The app
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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';
|