react-x11 2.50.0 → 2.51.1

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
@@ -464,6 +464,10 @@ npx react-devtools # 1. start the standalone UI
464
464
  REACT_X11_DEVTOOLS=1 npm run examples:dashboard # 2. run any example with the bridge on
465
465
  ```
466
466
 
467
+ Under `npx react-x11 dev` the bridge is on by default (with
468
+ `react-devtools-core` installed): run `npx react-devtools`, before or after,
469
+ and the app shows up.
470
+
467
471
  The component tree, props and hook state show up live in the DevTools
468
472
  window, and editing any of them re-renders the app. Hovering an element in
469
473
  the tree tints its rect in the app's window; the toolbar's crosshair picks
@@ -498,7 +502,7 @@ Alt+Click any rendered element (Option+Click on macOS, either backend) to
498
502
  open the JSX line that created it in your editor. `REACT_X11_EDITOR` picks
499
503
  the CLI and enables the feature in one go; `REACT_X11_CLICK_TO_COMPONENT=1`
500
504
  does the same with the default editor
501
- (`cursor`). See [docs/click-to-component.md](docs/click-to-component.md).
505
+ (`cursor`), and `npx react-x11 dev` turns it on by default. See [docs/click-to-component.md](docs/click-to-component.md).
502
506
 
503
507
  ## TypeScript
504
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.50.0",
3
+ "version": "2.51.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -112,7 +112,7 @@
112
112
  "yoga-layout": "^3.2.1"
113
113
  },
114
114
  "optionalDependencies": {
115
- "@windowkit/appkit": "^0.29.0",
115
+ "@windowkit/appkit": "^0.30.1",
116
116
  "@windowkit/wayland": "^3.1.1",
117
117
  "@windowkit/win32": "^0.0.9",
118
118
  "dbus-native": "^0.16.0",
@@ -1,7 +1,8 @@
1
1
  // Opt-in React DevTools bridge. Enabled by setting REACT_X11_DEVTOOLS=1 in
2
- // the environment; requires the `react-devtools-core` and `ws` dev
3
- // dependencies. Start the standalone UI first (`npx react-devtools`), then
4
- // run the app: REACT_X11_DEVTOOLS=1 npm run examples:dashboard
2
+ // the environment, and on by default under `react-x11 dev`; requires the
3
+ // `react-devtools-core` dev dependency. Run the standalone UI
4
+ // (`npx react-devtools`) and the app, in either order:
5
+ // REACT_X11_DEVTOOLS=1 npm run examples:dashboard
5
6
  //
6
7
  // Most of what DevTools can do costs this file nothing: the component tree,
7
8
  // props/state/hooks, and every edit the frontend makes go through the
@@ -12,12 +13,14 @@
12
13
  // Each of those is an agent listener or an option below.
13
14
  import { spawn } from 'node:child_process';
14
15
  import fs from 'node:fs';
16
+ import { createRequire } from 'node:module';
15
17
  import os from 'node:os';
16
18
  import path from 'node:path';
17
19
  import { setInspectHandler } from './events.js';
18
20
  import { flattenStyle, isStyleProp, STYLE_PROP_NAMES } from './styles.js';
19
21
 
20
22
  let api = null;
23
+ let prepared = null;
21
24
  let connected = false;
22
25
 
23
26
  // ---------------------------------------------------------------------------
@@ -107,9 +110,16 @@ export function createStorage(persist) {
107
110
  /**
108
111
  * Install the DevTools global hook. Must run before the first React commit
109
112
  * so mounted roots are observed — Reconciler.js awaits this at module load
110
- * when REACT_X11_DEVTOOLS is set.
113
+ * when REACT_X11_DEVTOOLS is set — and before React Refresh patches the
114
+ * hook, which `react-x11 dev` calls it early for: the backend installs no
115
+ * hook where one exists, and Refresh's is a stub. Once per process.
111
116
  */
112
- export async function prepare() {
117
+ export function prepare() {
118
+ prepared ??= install();
119
+ return prepared;
120
+ }
121
+
122
+ async function install() {
113
123
  try {
114
124
  // Each hook's source location is read off a stack captured at the hook
115
125
  // dispatch, and V8 keeps ten frames — fewer than a loader leaves
@@ -122,9 +132,6 @@ export async function prepare() {
122
132
  // react-devtools-core's backend bundle expects browser-ish globals
123
133
  global.self ??= global;
124
134
  global.window ??= global;
125
- if (!global.WebSocket) {
126
- global.WebSocket = (await import('ws')).default;
127
- }
128
135
  // The update-tracing overlay schedules its redraws on animation
129
136
  // frames, which Node does not have; without these the first traced
130
137
  // commit throws inside the backend. Unref'd, because an overlay must
@@ -162,6 +169,7 @@ export async function prepare() {
162
169
  const mod = await import('react-devtools-core');
163
170
  // v7 exposes the API on the default export under node ESM interop
164
171
  api = mod.default?.initialize ? mod.default : mod;
172
+ HostSocket = hostSocketClass();
165
173
  // The hook settings the user last chose in the DevTools UI — console
166
174
  // patching and friends. `initialize()` takes them, `onSettingsUpdated`
167
175
  // (below) records them, so a setting survives a restart.
@@ -170,7 +178,7 @@ export async function prepare() {
170
178
  api = null;
171
179
  console.warn(
172
180
  'react-x11: REACT_X11_DEVTOOLS is set but devtools could not be loaded. ' +
173
- 'Install react-devtools-core and ws. Original error: ' +
181
+ 'Install react-devtools-core. Original error: ' +
174
182
  err.message,
175
183
  );
176
184
  }
@@ -254,6 +262,80 @@ export function reloadAndProfile(recordChangeDescriptions, recordTimeline) {
254
262
  // Connect
255
263
  // ---------------------------------------------------------------------------
256
264
 
265
+ // The backend keeps trying to connect for as long as the process lives — a
266
+ // timer every two seconds while no UI is listening, and an open socket while
267
+ // one is — and either is enough to keep a Node process up after the app's
268
+ // last window closed. Under `react-x11 dev`, where it is on by default, that
269
+ // was every session hanging on close. Neither is reachable from the options:
270
+ // so the backend's own code runs in a scope where the timers it starts are
271
+ // unref'd and the sockets it opens are, too. The scope is entered where the
272
+ // backend is — our connect call, its socket's close and error handlers that
273
+ // schedule the retry, and the timers it set in the scope — and swaps the
274
+ // globals only for that synchronous stretch, so the app's own `setTimeout`
275
+ // and `WebSocket` are never the ones changed.
276
+ let HostSocket = null;
277
+
278
+ function inBackend(fn) {
279
+ const { setTimeout: appSetTimeout, WebSocket: appWebSocket } = globalThis;
280
+ globalThis.setTimeout = (callback, ...rest) => {
281
+ const timer = appSetTimeout(
282
+ typeof callback === 'function'
283
+ ? (...args) => inBackend(() => callback(...args))
284
+ : callback,
285
+ ...rest,
286
+ );
287
+ timer?.unref?.();
288
+ return timer;
289
+ };
290
+ if (HostSocket) globalThis.WebSocket = HostSocket;
291
+ try {
292
+ return fn();
293
+ } finally {
294
+ globalThis.setTimeout = appSetTimeout;
295
+ globalThis.WebSocket = appWebSocket;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The backend's WebSocket: `ws`, from react-devtools-core's own
301
+ * dependencies, with its socket unref'd once open and its close and error
302
+ * handlers run in the backend's scope. Falls back to the platform's
303
+ * WebSocket, which works and holds the process open.
304
+ */
305
+ function hostSocketClass() {
306
+ let WS;
307
+ try {
308
+ WS = createRequire(import.meta.resolve('react-devtools-core'))('ws');
309
+ } catch {
310
+ return globalThis.WebSocket ?? null;
311
+ }
312
+ class Socket extends WS {
313
+ constructor(...args) {
314
+ super(...args);
315
+ this.on('open', () => this._socket?.unref?.());
316
+ }
317
+ }
318
+ for (const name of ['onclose', 'onerror']) {
319
+ const base = Object.getOwnPropertyDescriptor(WS.prototype, name);
320
+ if (!base?.set) continue;
321
+ Object.defineProperty(Socket.prototype, name, {
322
+ configurable: true,
323
+ get() {
324
+ return base.get.call(this);
325
+ },
326
+ set(handler) {
327
+ base.set.call(
328
+ this,
329
+ typeof handler === 'function'
330
+ ? (...args) => inBackend(() => handler(...args))
331
+ : handler,
332
+ );
333
+ },
334
+ });
335
+ }
336
+ return Socket;
337
+ }
338
+
257
339
  /** Connect the backend to the standalone DevTools app and register the
258
340
  * renderer. Called by Reconciler.js right after the renderer is created. */
259
341
  export function connect(renderer) {
@@ -261,39 +343,41 @@ export function connect(renderer) {
261
343
  connected = true;
262
344
  const profiling = readProfilingOnStart();
263
345
 
264
- api.connectToDevTools({
265
- isAppActive: () => true,
266
- host: process.env.REACT_X11_DEVTOOLS_HOST || 'localhost',
267
- port: Number(process.env.REACT_X11_DEVTOOLS_PORT) || 8097,
268
- // The style editor: `style` is a react-x11 style prop (an object, or a
269
- // nested array of them), which is the shape DevTools' own resolver
270
- // expects to be handed flat. `validAttributes` is what it offers to
271
- // add to an element.
272
- resolveRNStyle: resolveStyle,
273
- nativeStyleEditorValidAttributes: STYLE_PROP_NAMES,
274
- // Hook settings (console patching and so on) are the user's, not the
275
- // run's: keep them where the next run will find them.
276
- onSettingsUpdated: (settings) => {
277
- try {
278
- global.localStorage?.setItem(
279
- HOOK_SETTINGS_KEY,
280
- JSON.stringify(settings),
281
- );
282
- } catch {
283
- // an unwritable store is not worth a warning per keystroke
284
- }
285
- },
286
- // The backend's own check for this asks whether synchronous XHR works,
287
- // which is a browser question — the honest answer for a node process
288
- // that can re-exec itself is yes.
289
- isReloadAndProfileSupported: true,
290
- isProfiling: profiling != null,
291
- onReloadAndProfile: reloadAndProfile,
292
- onReloadAndProfileFlagsReset: () => {
293
- delete process.env.REACT_X11_DEVTOOLS_PROFILING;
294
- delete process.env.REACT_X11_DEVTOOLS_SESSION;
295
- },
296
- });
346
+ inBackend(() =>
347
+ api.connectToDevTools({
348
+ isAppActive: () => true,
349
+ host: process.env.REACT_X11_DEVTOOLS_HOST || 'localhost',
350
+ port: Number(process.env.REACT_X11_DEVTOOLS_PORT) || 8097,
351
+ // The style editor: `style` is a react-x11 style prop (an object, or a
352
+ // nested array of them), which is the shape DevTools' own resolver
353
+ // expects to be handed flat. `validAttributes` is what it offers to
354
+ // add to an element.
355
+ resolveRNStyle: resolveStyle,
356
+ nativeStyleEditorValidAttributes: STYLE_PROP_NAMES,
357
+ // Hook settings (console patching and so on) are the user's, not the
358
+ // run's: keep them where the next run will find them.
359
+ onSettingsUpdated: (settings) => {
360
+ try {
361
+ global.localStorage?.setItem(
362
+ HOOK_SETTINGS_KEY,
363
+ JSON.stringify(settings),
364
+ );
365
+ } catch {
366
+ // an unwritable store is not worth a warning per keystroke
367
+ }
368
+ },
369
+ // The backend's own check for this asks whether synchronous XHR works,
370
+ // which is a browser question — the honest answer for a node process
371
+ // that can re-exec itself is yes.
372
+ isReloadAndProfileSupported: true,
373
+ isProfiling: profiling != null,
374
+ onReloadAndProfile: reloadAndProfile,
375
+ onReloadAndProfileFlagsReset: () => {
376
+ delete process.env.REACT_X11_DEVTOOLS_PROFILING;
377
+ delete process.env.REACT_X11_DEVTOOLS_SESSION;
378
+ },
379
+ }),
380
+ );
297
381
 
298
382
  // No argument: react-reconciler 0.33 takes none, and the object this used
299
383
  // to pass was discarded in silence. The renderer's name and version now
package/src/Reconciler.js CHANGED
@@ -592,16 +592,15 @@ let integrationsSettled = false;
592
592
  function loadIntegrations() {
593
593
  if (integrationsSettled) return null;
594
594
  if (
595
- !process.env.REACT_X11_DEVTOOLS &&
596
- !process.env.REACT_X11_CLICK_TO_COMPONENT &&
597
- !process.env.REACT_X11_EDITOR &&
595
+ !envFlag('REACT_X11_DEVTOOLS') &&
596
+ !clickToComponentWanted() &&
598
597
  !process.env.REACT_X11_TRACE
599
598
  ) {
600
599
  return null;
601
600
  }
602
601
  if (integrations) return integrations;
603
602
  integrations = (async () => {
604
- if (process.env.REACT_X11_DEVTOOLS) {
603
+ if (envFlag('REACT_X11_DEVTOOLS')) {
605
604
  // Install the DevTools hook before any React commit, and register the
606
605
  // renderer with the standalone DevTools app.
607
606
  const devtools = await import('./DevToolsIntegration.js');
@@ -609,12 +608,7 @@ function loadIntegrations() {
609
608
  devtools.connect(Renderer);
610
609
  }
611
610
 
612
- if (
613
- process.env.REACT_X11_CLICK_TO_COMPONENT ||
614
- process.env.REACT_X11_EDITOR
615
- ) {
616
- // Naming an editor already means you want the feature on — no need to
617
- // also set REACT_X11_CLICK_TO_COMPONENT=1 just to pick one.
611
+ if (clickToComponentWanted()) {
618
612
  const clickToComponent = await import('./ClickToComponent.js');
619
613
  clickToComponent.install();
620
614
  }
@@ -632,6 +626,28 @@ function loadIntegrations() {
632
626
  return integrations;
633
627
  }
634
628
 
629
+ /**
630
+ * Whether to install click-to-component. Naming an editor already means you
631
+ * want the feature on — no need to also set REACT_X11_CLICK_TO_COMPONENT=1
632
+ * just to pick one — and `0` turns it off whatever else is set, which is
633
+ * the way out of `react-x11 dev`, where it is on by default and
634
+ * REACT_X11_EDITOR also picks the error window's editor.
635
+ */
636
+ function clickToComponentWanted() {
637
+ if (process.env.REACT_X11_CLICK_TO_COMPONENT === '0') return false;
638
+ return (
639
+ envFlag('REACT_X11_CLICK_TO_COMPONENT') ||
640
+ Boolean(process.env.REACT_X11_EDITOR)
641
+ );
642
+ }
643
+
644
+ /** A switch in the environment: set, and not `0` — the way out of a
645
+ * default `react-x11 dev` turns on. */
646
+ function envFlag(name) {
647
+ const value = process.env[name];
648
+ return Boolean(value) && value !== '0';
649
+ }
650
+
635
651
  /**
636
652
  * Open a connection this process owns. The wrapper is here for one reason:
637
653
  * to say what is wrong when there is no server, which is the first thing
package/src/cli/dev.js CHANGED
@@ -27,12 +27,26 @@
27
27
  // missing, names them with the versions the loader is written against, and
28
28
  // installs them with the project's own package manager when the developer
29
29
  // says yes. Without a terminal to ask on it prints the command instead.
30
+ // - **It turns click-to-component on.** Alt+Click (Option+Click on macOS)
31
+ // opens the JSX line an element came from (docs/click-to-component.md):
32
+ // a development tool, and this is the command a developer runs, so it is
33
+ // on unless REACT_X11_CLICK_TO_COMPONENT=0 says otherwise. Set here, in
34
+ // the environment, because that is where the renderer reads it when the
35
+ // first root is made — and where a toolchain restart inherits it from.
36
+ // - **It connects to React DevTools** whenever `npx react-devtools` is
37
+ // running, before or after the app started, the way a browser with the
38
+ // extension does: on by default where react-devtools-core is installed,
39
+ // unless REACT_X11_DEVTOOLS=0. Where it is not, and a DevTools UI is
40
+ // listening at start, it says what to install. Prepared before React
41
+ // Refresh loads, because Refresh installs a stub hook where it finds none,
42
+ // and the backend installs none where it finds one.
30
43
  // - **It runs the project's react-x11.** A second copy of the renderer is a
31
44
  // second React root nobody refreshes, so a global or npx-cached
32
45
  // `react-x11` hands over to the copy the file itself imports.
33
46
  import { spawnSync } from 'node:child_process';
34
47
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
35
48
  import * as nodeModule from 'node:module';
49
+ import { connect } from 'node:net';
36
50
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
37
51
  import { createInterface } from 'node:readline/promises';
38
52
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -122,6 +136,18 @@ export async function runDev(file, options = {}) {
122
136
  return null;
123
137
  }
124
138
 
139
+ process.env.REACT_X11_CLICK_TO_COMPONENT ??= '1';
140
+ if (process.env.REACT_X11_DEVTOOLS === undefined) {
141
+ if (resolves('react-devtools-core')) {
142
+ process.env.REACT_X11_DEVTOOLS = '1';
143
+ } else {
144
+ suggestDevTools({ projectDir, log });
145
+ }
146
+ }
147
+ if (envFlag(process.env.REACT_X11_DEVTOOLS)) {
148
+ await (await import('../DevToolsIntegration.js')).prepare();
149
+ }
150
+
125
151
  // Before anything below loads: a module's source maps are read when it
126
152
  // loads, and only once this is on.
127
153
  process.setSourceMapsEnabled(true);
@@ -386,6 +412,48 @@ function rootContent(node) {
386
412
  return { windows, drawn };
387
413
  }
388
414
 
415
+ /** Whether `specifier` resolves from react-x11, which is where it is
416
+ * imported from. */
417
+ function resolves(specifier) {
418
+ try {
419
+ import.meta.resolve(specifier);
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ /** The renderer's rule for a switch: set, and not `0`. */
427
+ function envFlag(value) {
428
+ return Boolean(value) && value !== '0';
429
+ }
430
+
431
+ /**
432
+ * Where react-devtools-core is missing: if a DevTools UI is listening (one
433
+ * TCP connect, in the background, never holding the process), say what to
434
+ * install for the app to show up in it. Silent otherwise — no DevTools
435
+ * running is the usual case, not one to print about.
436
+ */
437
+ function suggestDevTools({ projectDir, log }) {
438
+ const host = process.env.REACT_X11_DEVTOOLS_HOST || 'localhost';
439
+ const port = Number(process.env.REACT_X11_DEVTOOLS_PORT) || 8097;
440
+ const socket = connect({ host, port });
441
+ socket.unref();
442
+ socket.setTimeout(1000, () => socket.destroy());
443
+ socket.on('error', () => {});
444
+ socket.on('connect', () => {
445
+ socket.destroy();
446
+ const line = shellLine(
447
+ installCommand(packageManagerFor(projectDir), ['react-devtools-core']),
448
+ );
449
+ log(
450
+ `React DevTools is listening on ${host}:${port}, and this project ` +
451
+ 'cannot connect to it without react-devtools-core. Install it, and ' +
452
+ `the next run shows up there:\n\n ${line}\n`,
453
+ );
454
+ });
455
+ }
456
+
389
457
  /** The react-x11 package `path` imports, or null when it imports none. */
390
458
  export function reactX11For(path) {
391
459
  let entry;
package/src/cli/index.js CHANGED
@@ -17,6 +17,10 @@ Commands:
17
17
  export is mounted, inside a window of its own when it
18
18
  renders none; a file that mounts its own root runs as it is.
19
19
  Arguments after the file are the app's.
20
+ Alt+Click (Option+Click on macOS) an element to open its
21
+ source; REACT_X11_CLICK_TO_COMPONENT=0 turns that off.
22
+ Connects to React DevTools (npx react-devtools) whenever
23
+ it runs; REACT_X11_DEVTOOLS=0 turns that off.
20
24
 
21
25
  Options:
22
26
  -h, --help Show this help.
package/src/cocoa/app.js CHANGED
@@ -1447,6 +1447,9 @@ export class CocoaApp {
1447
1447
  return this._animationEnds.get(ev.id)?.(ev);
1448
1448
  case 'window-live-resize':
1449
1449
  return this._routeLiveResize(ev);
1450
+ case 'window-fullscreen':
1451
+ case 'window-miniaturize':
1452
+ return this._routeWindowState(ev);
1450
1453
  case 'window-created':
1451
1454
  // a worker's window, made on the UI thread after the call that
1452
1455
  // answered its handle
@@ -1611,6 +1614,8 @@ export class CocoaApp {
1611
1614
  // this is the only thing the bridge says about it (`_recheckScreens`).
1612
1615
  this._recheckScreens();
1613
1616
  wnd._nativeResized(ev);
1617
+ // AppKit says nothing of a zoom but the resize it makes
1618
+ if (ev.type === 'window-resize') wnd._refreshStates();
1614
1619
  wnd.emit('resize', {
1615
1620
  width: wnd.width,
1616
1621
  height: wnd.height,
@@ -1676,6 +1681,22 @@ export class CocoaApp {
1676
1681
  wnd.emit('liveresize', { live: wnd.liveResizing });
1677
1682
  }
1678
1683
 
1684
+ /**
1685
+ * `window-fullscreen { fullscreen, phase }` and `window-miniaturize`
1686
+ * (@windowkit/appkit 0.30.0): the title bar's green button, the Dock, and
1687
+ * the same asked for through `setWmState`. The bridge has published the
1688
+ * state already — a full-screen transition's target from its `begin`,
1689
+ * before the animation, which is the moment an app takes its chrome away
1690
+ * — so the window reads it back and says `statechange` if it moved. A
1691
+ * user's own act, so it is input: its render goes out like a click's.
1692
+ */
1693
+ _routeWindowState(ev) {
1694
+ const wnd = this._window(ev);
1695
+ if (!wnd || wnd.destroyed) return;
1696
+ wnd._refreshStates();
1697
+ this._afterInput();
1698
+ }
1699
+
1679
1700
  /**
1680
1701
  * `surface-released`: a worker's frame took an IOSurface off a layer and
1681
1702
  * the frame that replaced it has committed (windowkit/appkit#52). The
@@ -1759,6 +1780,7 @@ export class CocoaApp {
1759
1780
  const wnd = this._window(ev);
1760
1781
  if (!wnd) return;
1761
1782
  if (name === 'focus') this._lastKeyWindow = wnd;
1783
+ wnd._refreshStates();
1762
1784
  wnd.emit(name, { buttons: 0, time: ev.time });
1763
1785
  this._afterInput();
1764
1786
  }
@@ -246,8 +246,10 @@ export function describeProcess(moduleUrl) {
246
246
  platform: process.platform,
247
247
  env: process.env,
248
248
  execArgv: process.execArgv,
249
- // Node's major version; Bun answers with one of its own choosing
250
- node: bun ? null : Number(process.versions.node.split('.')[0]),
249
+ // Node's major version; Bun answers with one of its own choosing, and a
250
+ // web page's `process` (the playground's) names no node at all — read
251
+ // here before any veto, so a throw would end every import of react-x11
252
+ node: bun ? null : Number(process.versions?.node?.split('.')[0]) || null,
251
253
  entry: typeof entry === 'string' && fs.existsSync(entry) ? entry : null,
252
254
  compiled: compiledExecutable(bun),
253
255
  late: startedRunning(bun, moduleUrl, entry),
@@ -11,6 +11,15 @@ import { CocoaDropTransport, dragSpec } from './dnd.js';
11
11
  import { CocoaLayerPresenter } from './presenter.js';
12
12
  import { CocoaPromotion } from './promotion.js';
13
13
 
14
+ /** The bridge verb each `_NET_WM_STATE` name maps onto (`setWmState`). */
15
+ const STATE_VERBS = {
16
+ fullscreen: 'setWindowFullScreen',
17
+ maximized: 'setWindowZoomed',
18
+ maximized_vert: 'setWindowZoomed',
19
+ maximized_horz: 'setWindowZoomed',
20
+ hidden: 'setWindowMiniaturized',
21
+ };
22
+
14
23
  let nextWindowId = 1;
15
24
  // The slack a frame clock allows under its interval, in ms: a timer's drift
16
25
  // and no more (`CocoaApp._frameDue`, `_frameWait`, `nextFrameAt`). It lives
@@ -275,6 +284,10 @@ export class CocoaWindow {
275
284
  // --- events --------------------------------------------------------------
276
285
 
277
286
  on(name, fn) {
287
+ // what a change is measured from: the states when the first listener
288
+ // came, which it reads for itself with getWmStates
289
+ if (name === 'statechange' && !this._lastStates)
290
+ this._lastStates = this.getWmStatesNow();
278
291
  let list = this._handlers.get(name);
279
292
  if (!list) this._handlers.set(name, (list = []));
280
293
  list.push(fn);
@@ -286,6 +299,14 @@ export class CocoaWindow {
286
299
  };
287
300
  }
288
301
 
302
+ /** ntk's spelling of the function `on` answers, for a listener held by
303
+ * the function it added (`useWindowState`'s `statechange`). */
304
+ off(name, fn) {
305
+ const list = this._handlers.get(name);
306
+ const at = list ? list.indexOf(fn) : -1;
307
+ if (at >= 0) list.splice(at, 1);
308
+ }
309
+
289
310
  emit(name, ev) {
290
311
  const list = this._handlers.get(name);
291
312
  if (!list) return;
@@ -449,33 +470,95 @@ export class CocoaWindow {
449
470
  setActions() {}
450
471
 
451
472
  /**
452
- * `_NET_WM_STATE` requests, as far as this backend has verbs for them:
453
- * `demands_attention` is the Dock bounce (`requestUserAttention`), held
454
- * until the state is removed or the window goes. Every other name resolves
455
- * `false` — ntk's own contract for a state the server cannot honour —
456
- * because the bridge has no zoom/miniaturize/fullscreen verbs yet
457
- * (windowkit/appkit#15 scoped them out); WindowNode swallows the false.
473
+ * `_NET_WM_STATE` requests, with the verb AppKit has for each:
474
+ *
475
+ * | name | verb |
476
+ * | --- | --- |
477
+ * | `fullscreen` | the green button's full-screen Space (`setWindowFullScreen`) |
478
+ * | `maximized`, `maximized_vert`, `maximized_horz` | the zoom to the standard frame (`setWindowZoomed`) — AppKit zooms both axes or neither |
479
+ * | `hidden` | to the Dock and back (`setWindowMiniaturized`) |
480
+ * | `demands_attention` | the Dock bounce (`requestUserAttention`), held until the state is removed or the window goes |
481
+ *
482
+ * Every other name resolves `false` — ntk's own contract for a state the
483
+ * server cannot honour — and so does a name whose verb a bridge older than
484
+ * @windowkit/appkit 0.30.0 does not have; WindowNode swallows the false.
485
+ * A transition is AppKit's to animate, and what happened comes back as
486
+ * `statechange` (`_refreshStates`).
458
487
  */
459
488
  setWmState(names, action = 'add') {
460
489
  const list = Array.isArray(names) ? names : [names];
490
+ const held = action === 'toggle' ? new Set(this.getWmStatesNow()) : null;
491
+ const wants = (name) => (held ? !held.has(name) : action !== 'remove');
461
492
  let honoured = true;
462
493
  for (const name of list) {
463
- if (name !== 'demands_attention') {
464
- honoured = false;
494
+ if (name === 'demands_attention') {
495
+ const holding = this._attentionRequest != null;
496
+ const on = action === 'toggle' ? !holding : action === 'add';
497
+ if (on && !holding && !this.destroyed) {
498
+ this._attentionRequest = this.app.requestAttention();
499
+ } else if (!on && holding) {
500
+ this.app.cancelAttention(this._attentionRequest);
501
+ this._attentionRequest = null;
502
+ }
465
503
  continue;
466
504
  }
467
- const held = this._attentionRequest != null;
468
- const wants = action === 'add' || (action === 'toggle' && !held);
469
- if (wants && !held && !this.destroyed) {
470
- this._attentionRequest = this.app.requestAttention();
471
- } else if (!wants && held) {
472
- this.app.cancelAttention(this._attentionRequest);
473
- this._attentionRequest = null;
505
+ const verb = STATE_VERBS[name];
506
+ const fn = verb && this._native[verb];
507
+ if (typeof fn !== 'function' || this.destroyed) {
508
+ honoured = false;
509
+ continue;
474
510
  }
511
+ // a toggle of an axis reads the pair: zoomed is both or neither
512
+ const on =
513
+ held && verb === 'setWindowZoomed'
514
+ ? !held.has('maximized_vert')
515
+ : wants(name);
516
+ fn.call(this._native, this._h, on);
475
517
  }
476
518
  return Promise.resolve(honoured);
477
519
  }
478
520
 
521
+ /**
522
+ * What AppKit has done with the window, in `_NET_WM_STATE` names — the
523
+ * names the X11 and Wayland windows report, so `useWindowState()` and
524
+ * `onStatesChange` read them the same way: `fullscreen`, the zoom as
525
+ * `maximized_vert` + `maximized_horz`, the Dock as `hidden`, the key
526
+ * window as `focused`. Read from the bridge's published window state, so
527
+ * a worker answers without a hop; `fullscreen` there says where a
528
+ * transition is going from the moment it starts, which is when an app
529
+ * hides its chrome. A bridge older than 0.30.0 publishes only `key`.
530
+ */
531
+ getWmStatesNow() {
532
+ const f = this._h != null ? this._native.getWindowFrame?.(this._h) : null;
533
+ if (!f) return [];
534
+ const out = [];
535
+ if (f.fullscreen) out.push('fullscreen');
536
+ if (f.zoomed) out.push('maximized_vert', 'maximized_horz');
537
+ if (f.miniaturized) out.push('hidden');
538
+ if (f.key) out.push('focused');
539
+ return out;
540
+ }
541
+
542
+ getWmStates() {
543
+ return Promise.resolve(this.getWmStatesNow());
544
+ }
545
+
546
+ /**
547
+ * Something that can change the states happened — a full-screen
548
+ * transition, the Dock, a resize (AppKit says nothing of a zoom but the
549
+ * resize), the key window moving — so read them again and say so if they
550
+ * moved. Free for a window nobody asked: no listener, no read.
551
+ */
552
+ _refreshStates() {
553
+ if (this.destroyed || !this._handlers.get('statechange')?.length) return;
554
+ const next = this.getWmStatesNow();
555
+ const was = this._lastStates ?? [];
556
+ this._lastStates = next;
557
+ if (was.length === next.length && was.every((n, i) => n === next[i]))
558
+ return;
559
+ this.emit('statechange', next);
560
+ }
561
+
479
562
  // --- drag and drop (src/cocoa/dnd.js) ------------------------------------
480
563
 
481
564
  /**
@@ -247,7 +247,10 @@ export const Scrollable = (Base) =>
247
247
  // NaN, which a scroll pane in there measured its content from and
248
248
  // threw on, out of the frame. The box itself has its empty rect;
249
249
  // what is inside is placed when it is shown and laid out again.
250
- if (this.style.display !== 'none') {
250
+ // Both routes to it: the style, and the `hidden` React sets for
251
+ // `<Activity>` and `<Suspense>` (`setHidden`), which reaches yoga
252
+ // without touching the style.
253
+ if (this.style.display !== 'none' && !this.hidden) {
251
254
  this._absolutizeChildren(
252
255
  this.abs.x,
253
256
  this.abs.y,
@@ -429,9 +429,12 @@ export interface WindowProps
429
429
  * Applied before the window is mapped, which is the only way to open
430
430
  * already fullscreen rather than flashing at the normal size first.
431
431
  *
432
- * On the cocoa backend `demands_attention` bounces the Dock icon until
432
+ * On the cocoa backend `fullscreen` is the green button's full-screen
433
+ * Space, `maximized` (either axis) the zoom to the standard frame,
434
+ * `hidden` the Dock, and `demands_attention` bounces the Dock icon until
433
435
  * the app is activated and is cancelled when the state is removed; the
434
- * other names have no verb in the bridge yet and resolve to nothing.
436
+ * other names have no AppKit equivalent and resolve to nothing, as the
437
+ * first three do on a bridge older than @windowkit/appkit 0.30.0.
435
438
  */
436
439
  states?: WindowStateName[];
437
440
  /** Sugar for `states={['fullscreen']}`; they union. */
@@ -127,7 +127,10 @@ export interface WindowState {
127
127
  /** Both axes. One axis alone shows up in `states`. */
128
128
  readonly maximized: boolean;
129
129
  /** What the window manager actually did, not what `<window fullscreen>`
130
- * asked for. */
130
+ * asked for — the title bar's button included. On the cocoa backend it
131
+ * turns as AppKit's transition begins, before the animation, so chrome
132
+ * an app hides in full screen goes while the window grows rather than
133
+ * after. */
131
134
  readonly fullscreen: boolean;
132
135
  /** The raw `_NET_WM_STATE` names, e.g. `['maximized_vert', 'focused']` —
133
136
  * what the window manager put there, where `<window states>` is what was