react-x11 2.45.3 → 2.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.45.3",
3
+ "version": "2.46.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -221,6 +221,10 @@
221
221
  "./cocoa-main": {
222
222
  "types": "./src/cocoa/main.d.ts",
223
223
  "default": "./src/cocoa/main.js"
224
+ },
225
+ "./frame/child": {
226
+ "types": "./src/frame/child.d.ts",
227
+ "default": "./src/frame/child.js"
224
228
  }
225
229
  },
226
230
  "types": "./src/index.d.ts"
@@ -143,6 +143,19 @@ export const WORKER_START = `(function (process, global) {
143
143
  // process for ever.
144
144
  const WORKER_END_MS = 5000;
145
145
 
146
+ // Where Node starts an app (`startedRunning`): the script that loads the
147
+ // `--require` preloads and then the entry, and the module that runs the
148
+ // entry, an ES module's graph from an async function.
149
+ const ENTRY_POINT = new Set([
150
+ 'node:internal/main/run_main_module',
151
+ 'node:internal/modules/run_main',
152
+ ]);
153
+
154
+ // How a process is started under an ES module loader, as a flag of its own
155
+ // or in NODE_OPTIONS: `--import tsx`, `--import=tsx`, `--loader
156
+ // ts-node/esm`, `--experimental-loader ./hooks.mjs`.
157
+ const LOADER_FLAG = /^--(?:import|loader|experimental-loader)(?:=|$)/;
158
+
146
159
  /**
147
160
  * Why this import should not move the app onto a worker, or null when it
148
161
  * should. A pure answer over what the process says about itself, so that
@@ -152,6 +165,8 @@ export function relaunchVeto({
152
165
  isMainThread: main,
153
166
  platform,
154
167
  env,
168
+ execArgv,
169
+ node,
155
170
  entry,
156
171
  compiled,
157
172
  late,
@@ -184,13 +199,33 @@ export function relaunchVeto({
184
199
  ) {
185
200
  return 'a test runner';
186
201
  }
187
- // Imported after the app started running — a dynamic import from a
188
- // timer, say. Relaunching would run again, on the worker, everything the
189
- // entry has already done on this thread.
202
+ // Imported after the app started running: by an `import()` from a timer,
203
+ // say, or one the entry's body awaits. Relaunching would run again, on
204
+ // the worker, everything the entry has already done on this thread.
190
205
  if (late) return 'imported after the app started running';
206
+ // Started under an ES module loader, before Node 24. The worker gets the
207
+ // loader's flags, and whether its hooks follow is the loader's to say:
208
+ // tsx, the one an app is likeliest to run under, keeps them to the main
209
+ // thread on Node 20 and 22 — before 22.22.3, by tsx 4.23's own check —
210
+ // and a worker without them stops on the entry's first .jsx or .ts module
211
+ // (measured, 20.20 and 22.16). No loader can be asked before the move, so
212
+ // any `--import` or `--loader` counts. From 24 an app under one moves,
213
+ // and tsx follows it there from 4.21.1 on 24.11.1 (an older one does not,
214
+ // docs/macos.md); `react-x11/cocoa-main` moves one on 20 or 22 whose
215
+ // loader follows.
216
+ if (node && node < 24 && startedUnderLoader(execArgv, env.NODE_OPTIONS)) {
217
+ return 'an ES module loader before Node 24';
218
+ }
191
219
  return null;
192
220
  }
193
221
 
222
+ /** Whether node's flags, its own or NODE_OPTIONS', name an ES module loader. */
223
+ function startedUnderLoader(execArgv = [], nodeOptions = '') {
224
+ return [...execArgv, ...nodeOptions.split(/\s+/)].some((flag) =>
225
+ LOADER_FLAG.test(flag),
226
+ );
227
+ }
228
+
194
229
  /** What `relaunchVeto` reads, from this process. */
195
230
  export function describeProcess(moduleUrl) {
196
231
  const bun = globalThis.Bun;
@@ -199,6 +234,9 @@ export function describeProcess(moduleUrl) {
199
234
  isMainThread,
200
235
  platform: process.platform,
201
236
  env: process.env,
237
+ execArgv: process.execArgv,
238
+ // Node's major version; Bun answers with one of its own choosing
239
+ node: bun ? null : Number(process.versions.node.split('.')[0]),
202
240
  entry: typeof entry === 'string' && fs.existsSync(entry) ? entry : null,
203
241
  compiled: compiledExecutable(bun),
204
242
  late: startedRunning(bun, moduleUrl, entry),
@@ -216,15 +254,36 @@ function compiledExecutable(bun) {
216
254
  }
217
255
 
218
256
  /**
219
- * Whether the entry's own code has started. Node's event loop has not
220
- * turned while the entry's static imports evaluate —
221
- * `performance.nodeTiming.loopStart` is -1 until it does. Bun reports 1
222
- * there from the first line; what tells in Bun is the entry's record in the
223
- * module cache, which is there once the entry has been evaluated and
224
- * absent while its imports are (both measured, Node 26 and Bun 1.4).
257
+ * Whether the entry's own code has started: whether this import is one the
258
+ * app made after its start, rather than part of it.
259
+ *
260
+ * Node tells by where the import is evaluated from. It starts an app from
261
+ * two modules of its own (`ENTRY_POINT`): a CommonJS entry, and every
262
+ * `require()` of its first run and of a `--require` preload, evaluate under
263
+ * them synchronously, and an ES module entry's graph — static imports, an
264
+ * `--import` preload's — from an async function there, which V8 keeps on
265
+ * the stack as an async frame. An `import()` is evaluated by a job of its
266
+ * own, whose stack ends in the module loader, or in the app's own code when
267
+ * that awaits it: an `await import()` in the entry's body, an async
268
+ * function of the app's. Measured on Node 20.20, 22.16, 23.2, 24.12, 25.4
269
+ * and 26.0, static imports nested and behind a module with a top-level
270
+ * await, and under tsx, `--experimental-loader`, source maps and a stack
271
+ * trace limit of 0. A Node that moved those modules would read every
272
+ * import as late, which keeps the app where it is rather than running it
273
+ * twice; test/cocoa-relaunch.test.js would say so on any OS.
274
+ *
275
+ * `performance.nodeTiming.loopStart`, -1 until the event loop turns, is the
276
+ * public signal and is wrong both ways: Node 20 and 22 turn the loop while
277
+ * they load an ES module entry, so no such entry moved there, and Node 24
278
+ * and later evaluate an `import()` the entry makes before the loop's first
279
+ * turn, so an app that made one moved and ran its entry twice.
280
+ *
281
+ * Bun reports 1 there from the first line; what tells in Bun is the entry's
282
+ * record in the module cache, which is there once the entry has been
283
+ * evaluated and absent while its imports are (measured, Bun 1.4).
225
284
  */
226
285
  function startedRunning(bun, moduleUrl, entry) {
227
- if (!bun) return performance.nodeTiming?.loopStart > 0;
286
+ if (!bun) return !evaluatedFromEntryPoint();
228
287
  try {
229
288
  return Boolean(createRequire(moduleUrl).cache?.[entry]?.loaded);
230
289
  } catch {
@@ -232,13 +291,40 @@ function startedRunning(bun, moduleUrl, entry) {
232
291
  }
233
292
  }
234
293
 
294
+ /** Whether this stack, async frames and all, comes from `ENTRY_POINT`. */
295
+ function evaluatedFromEntryPoint() {
296
+ const { prepareStackTrace, stackTraceLimit } = Error;
297
+ let sites;
298
+ try {
299
+ // Reflect.set: an Error the app has frozen is not a reason to throw
300
+ // from its import of react-x11
301
+ Reflect.set(Error, 'stackTraceLimit', Infinity);
302
+ Reflect.set(Error, 'prepareStackTrace', (_, callSites) => callSites);
303
+ const held = {};
304
+ Error.captureStackTrace(held);
305
+ sites = held.stack;
306
+ } finally {
307
+ Reflect.set(Error, 'prepareStackTrace', prepareStackTrace);
308
+ Reflect.set(Error, 'stackTraceLimit', stackTraceLimit);
309
+ }
310
+ if (!Array.isArray(sites)) return false;
311
+ for (const site of sites) {
312
+ const file = site.getFileName() ?? '';
313
+ if (ENTRY_POINT.has(file)) return true;
314
+ // the app's own code awaits this import, so it has run already
315
+ if (site.isAsync() && !file.startsWith('node:')) return false;
316
+ }
317
+ return false;
318
+ }
319
+
235
320
  /**
236
321
  * The import-time move (src/bootstrap.js): relaunch when nothing vetoes it
237
322
  * and a bridge that can run AppKit's loop is installed. Returns why it did
238
323
  * not, for anyone asking; when it does, it does not return.
239
324
  */
240
325
  export function relaunchOnImport(moduleUrl) {
241
- const veto = relaunchVeto(describeProcess(moduleUrl));
326
+ const facts = describeProcess(moduleUrl);
327
+ const veto = relaunchVeto(facts);
242
328
  if (veto) return veto;
243
329
  let native;
244
330
  try {
@@ -248,7 +334,7 @@ export function relaunchOnImport(moduleUrl) {
248
334
  return 'no cocoa bridge';
249
335
  }
250
336
  if (typeof native.runMain !== 'function') return 'the bridge has no runMain';
251
- return relaunch(describeProcess(moduleUrl).entry, native);
337
+ return relaunch(facts.entry, native);
252
338
  }
253
339
 
254
340
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `react-x11/frame/child` — the entry a `<Frame>` forks: a pane process's
3
+ * whole bootstrap, over the IPC channel `fork()` sets up. **Resolved, not
4
+ * imported**: a `transport` that starts the pane its own way — under a
5
+ * sandbox, with a runtime of its own choosing — forks
6
+ * `fileURLToPath(import.meta.resolve('react-x11/frame/child'))`, and
7
+ * importing it anywhere else exits the process. No exports.
8
+ */
9
+ export {};
@@ -73,21 +73,22 @@ const childPath = () =>
73
73
 
74
74
  /** The default transport: fork this package's child entry. `execArgv` is
75
75
  * inherited, which is what carries a dev loader (tsx, the refresh loader)
76
- * into the pane; a bundled app forks plain JS and needs none. */
77
- function forkTransport({ src, display }) {
76
+ * into the pane; a bundled app forks plain JS and needs none. The pane's
77
+ * own flags and environment (`<Frame execArgv env>`) go on top. */
78
+ function forkTransport({ src, display, execArgv = [], env = {} }) {
78
79
  const child = fork(childPath(), [], {
79
80
  serialization: 'advanced',
80
81
  // dev loaders (tsx, the refresh loader) follow into the pane; the test
81
82
  // runner's own flags must not — `--test` in execArgv would run the pane
82
- // entry as a test file
83
- execArgv: process.execArgv.filter(
84
- (a) => a !== '--test' && !a.startsWith('--test-'),
85
- ),
86
- env: {
87
- ...process.env,
88
- REACT_X11_FRAME: '1',
89
- ...(display ? { DISPLAY: display } : {}),
90
- },
83
+ // entry as a test file. The pane's come after, so a flag it names is
84
+ // the one in force where the two disagree.
85
+ execArgv: [
86
+ ...process.execArgv.filter(
87
+ (a) => a !== '--test' && !a.startsWith('--test-'),
88
+ ),
89
+ ...execArgv,
90
+ ],
91
+ env: paneEnv(env, display),
91
92
  });
92
93
  void src; // resolved by the child from the hello, not from argv
93
94
  return {
@@ -121,6 +122,58 @@ function forkTransport({ src, display }) {
121
122
  };
122
123
  }
123
124
 
125
+ /**
126
+ * The environment a pane process starts with: this one's, with what the
127
+ * `<Frame>` names over it — `undefined` takes a variable away — and then
128
+ * the two that are not the app's to set: `REACT_X11_FRAME`, which is how a
129
+ * pane knows it is one (`isFramed`), and `DISPLAY`, which `display` names.
130
+ */
131
+ function paneEnv(env, display) {
132
+ const out = { ...process.env };
133
+ for (const [name, value] of Object.entries(env)) {
134
+ if (value === undefined) delete out[name];
135
+ else out[name] = value;
136
+ }
137
+ out.REACT_X11_FRAME = '1';
138
+ if (display) out.DISPLAY = display;
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * What a pane's process is started with, checked and as one string: the
144
+ * session's key, so that an `execArgv` array or an `env` object written in
145
+ * place — a new one each render — restarts nothing, and one that says
146
+ * something else restarts the pane. Throws on what a fork cannot take,
147
+ * which the session reports as a `spawn` failure.
148
+ */
149
+ function launchOf(execArgv, env) {
150
+ if (
151
+ execArgv !== undefined &&
152
+ (!Array.isArray(execArgv) || execArgv.some((a) => typeof a !== 'string'))
153
+ ) {
154
+ throw new TypeError(
155
+ 'react-x11: <Frame execArgv> is an array of strings — the flags the ' +
156
+ "pane's process is started with.",
157
+ );
158
+ }
159
+ if (
160
+ env !== undefined &&
161
+ (env === null ||
162
+ typeof env !== 'object' ||
163
+ Object.values(env).some((v) => v !== undefined && typeof v !== 'string'))
164
+ ) {
165
+ throw new TypeError(
166
+ "react-x11: <Frame env> maps a variable's name to a string, or to " +
167
+ 'undefined to take it away.',
168
+ );
169
+ }
170
+ // `undefined` is kept apart from a variable left alone, which
171
+ // JSON.stringify would drop
172
+ return JSON.stringify([execArgv ?? [], env ?? {}], (_, v) =>
173
+ v === undefined ? { unset: true } : v,
174
+ );
175
+ }
176
+
124
177
  function srcString(src) {
125
178
  if (src !== null && typeof src === 'object' && typeof src.href === 'string') {
126
179
  return src.href;
@@ -220,6 +273,8 @@ export function Frame({
220
273
  props = EMPTY_PROPS,
221
274
  style,
222
275
  display,
276
+ execArgv,
277
+ env: processEnv,
223
278
  bridge = true,
224
279
  fallback,
225
280
  focusable,
@@ -262,8 +317,20 @@ export function Frame({
262
317
  const source = srcString(src);
263
318
  const makeTransport = transport ?? forkTransport;
264
319
  const containerRef = useRef(null);
320
+ // the pane process's own flags and environment, by what they say
321
+ let launch;
322
+ let launchError = null;
323
+ try {
324
+ launch = launchOf(execArgv, processEnv);
325
+ } catch (err) {
326
+ launch = '';
327
+ launchError = err;
328
+ }
329
+ const paneProcess = useRef(null);
330
+ paneProcess.current = { execArgv: execArgv ?? [], env: processEnv ?? {} };
265
331
 
266
- // The session: one child process per (src, display, transport, restart).
332
+ // The session: one child process per (src, display, the process's flags
333
+ // and environment, transport, restart).
267
334
  // Declared before the update effect so that on the mounting commit the
268
335
  // hello goes out first and the update pass sees itself already sent.
269
336
  useEffect(() => {
@@ -297,7 +364,13 @@ export function Frame({
297
364
 
298
365
  let t;
299
366
  try {
300
- t = makeTransport({ src: source, display });
367
+ if (launchError) throw launchError;
368
+ t = makeTransport({
369
+ src: source,
370
+ display,
371
+ execArgv: paneProcess.current.execArgv,
372
+ env: paneProcess.current.env,
373
+ });
301
374
  } catch (err) {
302
375
  fail(Object.assign(err, { phase: 'spawn' }));
303
376
  return undefined;
@@ -439,7 +512,7 @@ export function Frame({
439
512
  // ask, and everything is unref'd so nothing holds the host open
440
513
  if (session.current === s) session.current = null;
441
514
  };
442
- }, [source, display, generation, makeTransport, canShowPane]);
515
+ }, [source, display, launch, generation, makeTransport, canShowPane]);
443
516
 
444
517
  // One update per commit that changed the pane's inputs, props and env in
445
518
  // the same message — so a theme flip and the state change that caused it
@@ -45,6 +45,22 @@ export interface FrameComponentProps {
45
45
  style?: StyleProp;
46
46
  /** X display for the pane process; defaults to the inherited `$DISPLAY`. */
47
47
  display?: string;
48
+ /**
49
+ * Flags for the pane's process, after the ones it inherits from this one
50
+ * (a dev loader's): `['--max-old-space-size=256']` bounds the pane's heap,
51
+ * which nothing inside the pane can. They are handed to the runtime that
52
+ * forks it, so they are that runtime's flags. A change restarts the pane;
53
+ * the same flags in a new array do not.
54
+ */
55
+ execArgv?: readonly string[];
56
+ /**
57
+ * Environment variables for the pane's process, over this one's; a value
58
+ * of `undefined` takes a variable away. `DISPLAY` is `display`'s and
59
+ * `REACT_X11_FRAME` marks the pane (`isFramed`), so neither can be set
60
+ * here. A change restarts the pane; the same variables in a new object do
61
+ * not.
62
+ */
63
+ env?: Readonly<Record<string, string | undefined>>;
48
64
  /**
49
65
  * Which bridged contexts follow the app into this pane: `true` (default)
50
66
  * for every registered one — the theme included — `false` for none, or an
@@ -75,10 +91,23 @@ export interface FrameComponentProps {
75
91
  * (a loopback pair into `runFrameChild`), and the door to running a pane
76
92
  * somewhere other than a child process. Advanced; see docs/frame.md.
77
93
  */
78
- transport?(options: { src: string; display?: string }): FrameTransport;
94
+ transport?(options: FrameTransportOptions): FrameTransport;
79
95
  ref?: Ref<FrameHandle>;
80
96
  }
81
97
 
98
+ /** What a transport is asked to start: the pane module, and the display,
99
+ * flags and environment `<Frame>` names for its process. The default
100
+ * transport forks `react-x11/frame/child` with them. */
101
+ export interface FrameTransportOptions {
102
+ src: string;
103
+ display?: string;
104
+ /** `<Frame execArgv>`, or none: added to what the pane inherits. */
105
+ execArgv: readonly string[];
106
+ /** `<Frame env>`, or none: over this process's environment, `undefined`
107
+ * taking a variable away. */
108
+ env: Readonly<Record<string, string | undefined>>;
109
+ }
110
+
82
111
  /** The parent's end of the wire to a pane. */
83
112
  export interface FrameTransport {
84
113
  /** May throw when the channel is gone or the message will not clone. */