react-x11 2.51.0 → 2.52.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.
Files changed (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +10 -3
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
package/docs/frame.md ADDED
@@ -0,0 +1,580 @@
1
+ # Panes in their own process: `<Frame>`
2
+
3
+ ```jsx
4
+ import { Frame } from 'react-x11';
5
+
6
+ <Frame
7
+ src={new URL('./charts.pane.js', import.meta.url)}
8
+ props={{ rows, range, onPick }}
9
+ style={{ flexGrow: 1, backgroundColor: '$surface' }}
10
+ fallback={({ error, restart }) => <Crashed error={error} onRetry={restart} />}
11
+ />;
12
+ ```
13
+
14
+ `<Frame>` mounts a module of your application in a **process of its own** and
15
+ embeds its output here, laid out like any other child. It is this package's
16
+ iframe: the same composition of a display boundary and a process boundary,
17
+ behind one element, and it works on X11, Cocoa and win32. The
18
+ [Wayland backend](wayland-backend.md) has neither of the mechanisms below, so
19
+ a `<Frame>` there fails at once: `fallback` renders and no pane is started.
20
+
21
+ How the display boundary is drawn differs, and it is worth knowing which one
22
+ you are on when reading a trace:
23
+
24
+ - **X11** — the pane renders into a real window of its own and the host
25
+ embeds it over [`<foreign>`](embedding.md). Two boundaries that already
26
+ existed separately, with `<Frame>` as the contract between them.
27
+ - **Cocoa** — there is no server to share, so the pane paints into
28
+ `IOSurface`s created shared and presents by message; the host points one
29
+ sublayer of its window at whichever surface the pane last presented. The
30
+ pane has no `NSWindow` at all, and runs no AppKit. Every other window it
31
+ makes — a menu, a dropdown's sheet, a tooltip, a dialog — it paints the
32
+ same way, and the host shows in an `NSWindow` of its own (see "Sizing,
33
+ stacking, input").
34
+ - **Windows** — the same shape as Cocoa with a **DirectComposition surface
35
+ handle** in the IOSurface's place: the pane makes the handle, draws into it
36
+ through a composition swapchain, and duplicates it into the host, which
37
+ gives it to a visual in its own window. The pane has no HWND.
38
+ [windows-embedding.md](windows-embedding.md) has the reasoning; the one
39
+ thing worth knowing from here is that the handle is named **once**, not per
40
+ frame — after that the compositor scans out of whatever the pane last
41
+ presented, so a pane frame costs no message, no fence and no copy.
42
+
43
+ Either way the host owns layout and hit testing and the pane owns its
44
+ drawing, which is the line that makes this **CPU offloading, not
45
+ isolation**.
46
+
47
+ What the process boundary buys is the part no amount of careful coding buys
48
+ in-process:
49
+
50
+ - **Parallelism where this framework is CPU-bound.** The pane's
51
+ reconciliation, layout, text shaping and painting run on another core. A
52
+ pane that grinds for 400ms does not delay the shell's input handling by a
53
+ microsecond — `npm run examples:frame` makes the difference visible with a
54
+ ticker you can watch freeze, or not.
55
+ - **Crash isolation.** An uncaught throw, a native-module crash, an OOM: the
56
+ pane dies, the `fallback` renders, `restart()` respawns. The shell never
57
+ knew.
58
+ - **Leak isolation.** A pane that leaks is a killable leak, bounded by the
59
+ heap its `execArgv` gives it
60
+ ([The pane process](#the-pane-process-flags-and-environment)), and its GC
61
+ pauses are its own.
62
+
63
+ And what it does not buy, said once here and again in
64
+ [security.md](security.md): **it is not a security boundary.** On X11 the
65
+ pane holds a full-privilege connection to the same X server — it can read
66
+ keys, take screenshots, move windows — and on Cocoa it is an ordinary child
67
+ process of your application with your file system and your network.
68
+ `<Frame>` contains a pane's _failures_, not its intentions; code you would
69
+ not run in-process is code you should not run at all.
70
+
71
+ ## The pane module
72
+
73
+ `src` names a module whose **default export is the pane's root component**:
74
+
75
+ ```jsx
76
+ // charts.pane.js
77
+ export default function ChartsPane({ rows, range, onPick }) {
78
+ return <box style={{ flexGrow: 1 }}>…</box>;
79
+ }
80
+ ```
81
+
82
+ Pass `src` as `new URL('./charts.pane.js', import.meta.url)` or an absolute
83
+ path — a relative string is refused, because it would resolve against
84
+ react-x11's own files rather than yours.
85
+
86
+ A pane module is an ordinary component module. The same file can run inline
87
+ (`import ChartsPane from './charts.pane.js'` and render it), be its own
88
+ application, or be a pane — `isFramed()` answers which, and the examples'
89
+ autorun guard is where to ask:
90
+
91
+ ```js
92
+ import { isFramed } from 'react-x11';
93
+
94
+ if (!isFramed() && !process.env.REACT_X11_NO_AUTORUN) {
95
+ const root = await createRoot();
96
+ root.render(<ChartsPane {...defaults} />);
97
+ }
98
+ ```
99
+
100
+ The pane process inherits the parent's `execArgv`, which is what carries a
101
+ dev loader (tsx, the refresh loader) into it during development; a bundled
102
+ application forks plain JavaScript and needs none. It connects to the
103
+ display the host names in `display`, or `$DISPLAY` like everything else.
104
+ What else it is started with is the `<Frame>`'s to say — see
105
+ [The pane process](#the-pane-process-flags-and-environment).
106
+
107
+ On macOS a pane stays on its process's main thread, where an app moves onto
108
+ a worker ([macos.md](macos.md#js-on-a-worker-a-ui-thread-of-the-bridges-own)):
109
+ it has no AppKit for that thread to keep, and the IPC channel it talks to
110
+ its host over is that thread's — a worker has no `process.send`. That holds
111
+ under `--import react-x11/cocoa-main` too, which a pane inherits whenever its
112
+ host was started under it.
113
+
114
+ Nor is a macOS pane an app of its own: it launches with AppKit's
115
+ `prohibited` activation policy, so it has no Dock tile and no ⌘-Tab entry
116
+ and is never the active app — its host's window has the focus and forwards
117
+ what the pane is owed. Launched with the bridge's default, `regular`, every
118
+ pane was another `node` in the Dock beside its host, one for each tab of the
119
+ components' browser example. A root that names `cocoa.activationPolicy`
120
+ itself still has the one it names.
121
+
122
+ ## Props: a bag of data, and stubs for the functions in it
123
+
124
+ `props` crosses by **structured clone** — Dates, Maps, TypedArrays and
125
+ cycles survive; component instances, refs and React elements do not. One
126
+ message goes out per parent commit that changed the bag (a shallow
127
+ `Object.is` pass decides "changed", so a parent re-rendering without
128
+ touching the pane's inputs sends nothing), and props and bridged context
129
+ travel in the **same message**, so a theme flip and the state change that
130
+ caused it land in the pane as one commit rather than a torn pair.
131
+
132
+ Functions anywhere in the bag become **fire-and-forget stubs** on the pane
133
+ side: calling one sends its arguments back and returns `undefined`. The
134
+ arguments are sanitized on the way — functions inside them are dropped, the
135
+ data around them kept — because a pane calls `onPick(item, ev)` the way it
136
+ would call any handler, and `ev` is full of methods.
137
+
138
+ Callback identity follows function identity: a handler the parent keeps
139
+ stable (`useCallback`) keeps its id across updates, and an id from the
140
+ previous update still lands — one update of grace, which is what covers a
141
+ click racing a props change. A stub stashed outside the props flow and
142
+ called two updates later is dropped with a warning, not delivered to the
143
+ wrong function.
144
+
145
+ Two rules of thumb that keep the wire small:
146
+
147
+ - **The pane owns its data.** Props are for queries, ids, ranges — the pane
148
+ fetches its own rows, the way an iframe fetches its own resources.
149
+ Shipping a dataset through `props` on every commit works, and is the
150
+ slowest possible way to have the pane know it.
151
+ - **The pane owns its animation.** An update is an IPC hop plus a pane
152
+ commit: the right price for "show this now", the wrong one for driving a
153
+ spinner at 60fps from the host.
154
+
155
+ ## Context: bridged explicitly, theme by default
156
+
157
+ React context cannot cross a process boundary, and most of it should not
158
+ try: appearance, locale, desktop settings and fonts are resolved from the
159
+ desktop identically on both sides, so each process just asks. The rule the
160
+ bridge implements: **bridge what the app defines; let each process resolve
161
+ what the desktop defines.**
162
+
163
+ The theme is the one ambient thing the app authors, so it crosses **by
164
+ default**: a `<ThemeProvider>` above a `<Frame>` reaches the pane as a real
165
+ `ThemeProvider` around it — both routes, `useTheme()` and `$token` styles —
166
+ and it is in the pane's very first commit, so there is no frame of default
167
+ palette first. The bridged provider wraps the pane's **window** itself, the
168
+ position it held in the host: the window's own background follows the app's
169
+ palette rather than the pane process's desktop, and the window is the top
170
+ of the node tree every `$token` beneath it resolves through — so a theme
171
+ toggle in the host moves the pane's background and its static text, not
172
+ only what re-renders through `useTheme()`. With no provider above the
173
+ frame, nothing is bridged and the pane follows the desktop by itself: same
174
+ answer, no bridge. A pane that wants its own look mounts its own provider,
175
+ which wins below the bridged one the way an inner provider always wins.
176
+
177
+ For a context of your own, create it with `createFrameContext` in a module
178
+ **both sides import** — which the pane already does, since its components
179
+ read the context from it. The import is what registers the key, so identity
180
+ needs no wiring:
181
+
182
+ ```js
183
+ // contexts.js — imported by the app and by the pane's components
184
+ import { createFrameContext } from 'react-x11';
185
+ export const Session = createFrameContext('session', null);
186
+ ```
187
+
188
+ ```jsx
189
+ // app side: an ordinary provider that also publishes to frames below it
190
+ <Session.Provider value={{ user, workspace }}>
191
+ <Frame src={paneUrl} … />
192
+ </Session.Provider>;
193
+
194
+ // pane side: nothing to wire
195
+ const { user } = Session.use();
196
+ ```
197
+
198
+ In-process it is an ordinary React context. Across a frame the value is
199
+ snapshotted, sent with the props, and recreated as a provider around the
200
+ pane — providers nest in the parent's own order, and a frame inside a pane
201
+ re-bridges automatically, because the pane's providers republish what they
202
+ received.
203
+
204
+ **Bridged values are data.** A value that will not structured-clone is
205
+ dropped with a warning naming the key, and that is deliberate: a bridged
206
+ `{ state, dispatch }` would hand live RPC stubs to every pane under the
207
+ provider — invisible wiring, with an unanswerable question about which
208
+ incarnation of a restarted pane holds which stub. Dispatchers travel in
209
+ `props`, where the wiring is visible. `serialize`/`revive` options on
210
+ `createFrameContext` cover a value that is data in spirit but a class in
211
+ shape.
212
+
213
+ `bridge` is the off switch the default owes: `bridge={false}` bridges
214
+ nothing, `bridge={['react-x11:theme', 'session']}` is an allowlist.
215
+
216
+ One honest limitation: the pane commits an IPC hop after the host, so a
217
+ theme flip repaints the shell a frame before the panes. Every asynchronous
218
+ boundary has this; nothing here pretends otherwise.
219
+
220
+ ## Lifecycle, from both sides
221
+
222
+ The host's view:
223
+
224
+ - **`onStarted({ pid, windowId })`** — the pane mounted; its window is
225
+ being embedded.
226
+ - **`onExit({ code, signal, expected })`** — the pane process ended.
227
+ `expected` is `true` when this side asked (unmount, `restart()`, a `src`
228
+ change) — and the event still fires after the frame unmounted, because
229
+ the exit a graceful close ends in always arrives after the unmount that
230
+ asked for it.
231
+ - **`fallback`** — what the rect shows when the pane crashed or could not
232
+ start: an element, or `({ error, restart }) => …`. `error.phase` says
233
+ where it went wrong: `spawn`, `load` (the commonest — a bad `src`),
234
+ `connect`, `handshake` (version skew), `runtime`, `send`, `embed`,
235
+ `unresponsive` (see [A pane that stops answering](#a-pane-that-stops-answering)),
236
+ or plain `exit`.
237
+ - **`ref`** — `{ restart(), pid }`.
238
+
239
+ The pane's view:
240
+
241
+ - **`useFrameClose(handler)`** — the host is letting the pane go: the
242
+ `<Frame>` unmounted, or the host app is exiting. The handler may return a
243
+ promise and may still call callback props — flushing through one is what
244
+ it is for. The patience is real but bounded: the host asks by message,
245
+ escalates to SIGTERM at 1.5s, SIGKILL at 4s. Not called on a crash; a
246
+ close handler is a courtesy, and anything that must survive a crash
247
+ belongs on disk before the close.
248
+ - **`isFramed()`** — see above.
249
+ - A host that dies without asking takes the IPC channel with it, and the
250
+ pane exits on the disconnect — no orphans either way.
251
+
252
+ ## Sizing, stacking, input
253
+
254
+ The embedded pane is a [`<foreign>`](embedding.md), and its rules apply
255
+ verbatim. The rect comes from `style` and yoga like any child — there is no
256
+ content-driven sizing across the boundary. The pane's window is a real X
257
+ child window, so **host-drawn content cannot overlap it**; a HUD or a
258
+ "reconnecting…" banner belongs in a sibling `<popup>`. Pointer events over
259
+ the pane are the pane's without the host ever seeing them; keys follow
260
+ [embedding.md's focus rules](embedding.md#focus-and-who-gets-the-keys) —
261
+ the host's handlers see every key first, and what they do not consume is
262
+ forwarded.
263
+
264
+ On Cocoa and Windows the pointer can never be over a pane's window,
265
+ because the pane has none. The host hit-tests the frame's box and forwards
266
+ the input over the channel. The cursor travels the other way. The pane's
267
+ tree names its cursor as a window's would, the pane sends it
268
+ (`pane-cursor`), and the host shows it over the box. It is the box's
269
+ default cursor, so a `cursor` in the frame's `style` still wins. On X11 the
270
+ pane's own window carries its cursor, as any window does. Keys go over as
271
+ the box's default action, as `<foreign>` forwards them, so a chord the host
272
+ answered above the frame never reaches the pane, and with their modifiers.
273
+
274
+ **Tab goes through a pane as through any subtree.** The frame is one stop in
275
+ the host's order, and the pane's own order hangs off it: Tab arriving at the
276
+ frame goes on into the pane at its first stop, or its last for Shift+Tab;
277
+ Tab while the frame has the focus is the pane's; and the pane running off an
278
+ end of its order hands Tab back, and the host goes on from the frame. The
279
+ pane lets go of its focus as it does, so the stop it was at shows no ring
280
+ while the host's next one does. A modal open in the pane keeps Tab going
281
+ round inside it. On X11 this is XEmbed's own chain — `FOCUS_IN` with
282
+ `FOCUS_FIRST` or `FOCUS_LAST` one way, `FOCUS_NEXT` or `FOCUS_PREV` the other,
283
+ which the pane's window answers and sends as an XEmbed client
284
+ (`_initXEmbedTab`). On Cocoa and Windows it is the same two messages over the
285
+ channel, `tabenter` and `pane-tab-out` (`EventManager._tabEnter`,
286
+ `_tabOut`). Before it, the host sent Tab on and cycled its own focus as
287
+ well, so Tab went between one of the pane's stops and one of the host's, and
288
+ on X11 Tab went round the pane's stops for good.
289
+
290
+ **A pane is drawn at the host's scale.** A pane process reads the screens
291
+ when it starts, and the host read them when it did. A desk that changed in
292
+ between gave the two different answers: a page forked for a new tab after
293
+ the desk went to 1x (a mirror set for a projector) was laid out at 1x in a
294
+ frame the host showed at 2x. Everything in it came out half the size of the
295
+ tabs beside it, and every click it was sent landed twice as far in. So the
296
+ host says what scale its window is drawn at, in the hello and in every
297
+ `pane-rect` (`displayScale`), and a Cocoa or Windows pane takes that one
298
+ instead of its own. Its window, its fonts and the windows it opens are made
299
+ at it from the first commit, and a later `pane-rect` that names another
300
+ scale lays the pane out again in the new unit. A `<box scale>` around the
301
+ `<Frame>` is not part of it: the frame's box grows with the zoom, and the
302
+ pane lays out at the window's scale in the room it was given.
303
+
304
+ **A pane's other windows.** On X11 a `<popup>` in a pane is a real window on
305
+ the pane's own connection. A Cocoa pane process runs no AppKit, so a window
306
+ made there exists and is never shown — a `<Select>` in a pane took the press
307
+ and opened nothing (#824). So the pane paints each window it makes besides
308
+ its own into a shared ring, as it paints itself (`CocoaPaneSubwindow`,
309
+ `src/cocoa/panewindow.js`), and the host makes the `NSWindow` and shows the
310
+ ring in it (`src/cocoa/panehost.js`):
311
+
312
+ - **Where it goes is the pane's to say.** Every `pane-rect` carries
313
+ `screen`, where the frame's box is on the screen in device pixels, sent
314
+ again when the box or the window moves. A popup the pane anchors to one of
315
+ its nodes is placed against it exactly as a window's popup is
316
+ (`windowOrigin`, src/anchor.js), and the host puts the window where the
317
+ pane says.
318
+ - **The window's life goes up, its input comes down.** `pane-window`
319
+ messages make, map, move, resize, grab with and destroy it, and its
320
+ `pane-present`s and `pane-cursor`s name it. Input on the host's window for
321
+ it goes back as `pane-event`s naming it. A grab is the host's, so a press
322
+ anywhere in the host's windows reaches the menu outside its bounds, which
323
+ is the dismissal. A managed window — a `<Dialog>` — also sends back the
324
+ user's moves and resizes and its close button.
325
+ - **The window's focus goes down too.** The host's window gaining or losing
326
+ it is sent to the pane as `focus`/`blur`, so a menu that closes when its
327
+ window loses focus (`useDismissOnWindowBlur`) closes when the user goes to
328
+ another application.
329
+ - **Nothing waits on the host until it is listening.** These messages are
330
+ held until the host's first `pane-rect`: it hears a pane from the moment
331
+ it has a `<Frame>`, and knows what to do with them only once the pane is
332
+ laid out in it. A window is then announced where it is, after that rect
333
+ has placed what is anchored to the pane again.
334
+ - **The pane's windows go with it.** A pane that exits or unmounts takes
335
+ every window the host shows for it, and their grab.
336
+
337
+ **A pane's videos.** A Cocoa pane runs no AppKit, so a player it made would
338
+ open the item and never hear AVFoundation say a word — a `<video src>` in a
339
+ pane sat on its poster and could not be started. So the host plays it
340
+ (`src/cocoa/paneplayer.js`): the hello tells the pane the host plays for
341
+ it, which is what makes `useSupports('mediaPlayback')` true there, and the
342
+ pane's `<video>` holds a player whose verbs go up as `pane-player` messages
343
+ and whose events come back down. The picture comes down the way the pane's
344
+ own goes up, the other way round: the host copies the frame showing into
345
+ one of a ring of three shared IOSurfaces and names it, and the pane copies
346
+ it out into the element's surface. A pane has no layers to lift a part
347
+ onto, so its video is always drawn, which costs the copy a covered video
348
+ costs in a window — the frame once into the ring, once out of it. A host
349
+ over a bridge with no player says nothing in the hello, and the pane
350
+ refuses `src` as X11 does. A pane that exits takes its players, and their
351
+ sound, with it.
352
+
353
+ **A pane resized.** On Cocoa the host gives the pane's layer its new size as
354
+ the window lays out, and the pane's frame of that size comes a pane frame
355
+ later. Core Animation's default stretched the frame the layer had to the new
356
+ bounds in between, so a page in a pane had its left column drawn scaled a
357
+ little at every step of a window dragged wider, and then drawn back at its
358
+ size. A pane whose frames of a new size come within the window's
359
+ live-resize budget, `createRoot({ cocoa: { resizeWait } })` (50 ms by
360
+ default), has its last frame shown at its size from the top left instead:
361
+ cropped on an axis the layer shrank on, and on one it grew on, its last
362
+ pixel carried over the rest — the last column across a window dragged
363
+ wider, the last row down one dragged taller. A page's edge is mostly its
364
+ background, so the strip continues it, a gradient included, where the
365
+ `<Frame>`'s own background showed through before. Where the edge crosses
366
+ content, a bottom row through a line of text, that content runs down the
367
+ strip in thin lines for the frame it lasts. One that falls behind the
368
+ budget is stretched across, which is the better picture of a long wait: a
369
+ width moves where a page's lines break, and a squeezed page is nearer to the
370
+ page laid out again than one cut off with a strip beside it. Down the pane
371
+ it stays at its size, cropped or its last row carried down, since a height
372
+ moves nothing a page lays out, and stretched down it was the page drawn too
373
+ tall for as long as the wait lasted. Behind is
374
+ judged by the frames that land: two in a row later than the budget, or none
375
+ at all for longer than it, and a frame well inside it shows the next at its
376
+ size again. A size the pane is never sent, because a newer one replaced it
377
+ while a frame was being painted, is no frame late, and a fast drag of a
378
+ page that keeps up stays at its size rather than taking one look at every
379
+ step and the other at every frame. Each gesture starts out at its size, and
380
+ `resizeWait: 0` stretches always, both ways, as before. An app can choose
381
+ apart from the budget, which is also how long AppKit holds the window's
382
+ edge for a frame: `createRoot({ cocoa: { paneStretch } })`, or
383
+ `REACT_X11_COCOA_PANE_STRETCH`, is `behind` for all of the above, `never`
384
+ for a last frame kept at its size and its edge carried over however late
385
+ the pane is, and `always` for one stretched both ways. And the pane hears that the window is being resized
386
+ live — AppKit's drag of its edge, from its begin to its end, which a pane
387
+ running no AppKit has no window to hear — with every size and on its own
388
+ where only that changes (`pane-rect`'s `live`, `pane-live`): its window's
389
+ `liveResizing` is the host's, so core defers measuring its content floors
390
+ for the drag's length and catches up once it ends, as a window of its own
391
+ does, and an element can put off what it would rather do once the drag is
392
+ over.
393
+
394
+ **A pane's frames are named by id, and the id outlives nothing.** On Cocoa
395
+ a present carries the IOSurface's id, and the host looks it up when it
396
+ reads the message. A resize has the pane make a new ring of buffers, and
397
+ the system gives a freed id to the next surface anyone makes — the pane's
398
+ next ring, another tab's, the host's own window. A present still in the
399
+ channel when its ring was freed was looked up as whichever surface had
400
+ the id by then: the pane's next frame cleared or half drawn, or another
401
+ tab's page, on glass for a frame at every few steps of a drag. So the host
402
+ answers each present it has looked up (`pane-shown`), and the pane keeps a
403
+ buffer it retired until the present naming it is answered: that is one
404
+ buffer as a rule, and never more than a ring of them, the oldest freed
405
+ first if the host stops answering. A pane's video comes down the same way
406
+ round: the pane answers each frame once it has looked its buffer up, and
407
+ the host keeps a buffer of a ring it retired — a stream that switched
408
+ renditions — until the frame naming it is answered. A lookup that finds a
409
+ surface of another size than its frame says is not copied out of at all.
410
+
411
+ **A pane draws only into a buffer the host is done with.** The ring holds
412
+ three buffers so that one present can be on its way while the next frame
413
+ draws: one on the layer, one named in the channel, one free. Each is made
414
+ the first time a frame takes it, copied over from the frame before: a drag
415
+ gives each size one frame, and a ring made whole at each was two buffers
416
+ made and cleared for nothing, 4.4 ms of every 12 ms frame of a drag of Zen
417
+ Garden 101 in the browser example on a Mac. A host two
418
+ presents behind — busy with a long frame of its own, a breakpoint's layout,
419
+ a window resize — left the next frame nothing but the buffer on the layer or
420
+ the one a present still in the channel named, and the frame drew into it
421
+ anyway: the host then showed a frame half drawn. So a frame takes a buffer
422
+ only once the host has answered a present after the last one that named it,
423
+ and waits on the clock for the next answer when there is none. An answer
424
+ says the host has looked the present up, not that the screen shows it, and
425
+ a host back from a long frame has the buffer it showed throughout still on
426
+ glass until its UI thread commits: so of the buffers the host is done with,
427
+ a frame takes one the WindowServer has let go of too, and waits for one, a
428
+ frame interval at most after the answer. A host that has not answered for
429
+ 250 ms no longer holds the pane up.
430
+
431
+ The same answer gates the frame an input gets on the spot
432
+ (`flushPendingFrames`): a pane paints on an input only once its last present
433
+ is answered and a frame interval has passed since it went, and otherwise the
434
+ next frame on the clock answers it. The interval costs an input a few
435
+ milliseconds at the display's rate, and is what keeps a pane at one frame a
436
+ refresh: without it, input faster than the display had a frame each — 245 a
437
+ second on a 120Hz panel, at 250 inputs a second — and the frames that went
438
+ faster than the WindowServer let go of buffers drew into ones it still read.
439
+
440
+ `backgroundColor` in the frame's `style` is what shows before the pane's
441
+ first frame and after one dies — the same server-painted rectangle
442
+ `<foreign>` documents.
443
+
444
+ ## The pane process: flags and environment
445
+
446
+ `execArgv` and `env` say what a pane's process is started with, beyond what
447
+ it inherits:
448
+
449
+ ```jsx
450
+ <Frame
451
+ src={new URL('./page.pane.js', import.meta.url)}
452
+ // a page that grows without bound costs its own process, at 256 MB
453
+ execArgv={['--max-old-space-size=256']}
454
+ env={{ PAGE_LOG: 'quiet', NODE_OPTIONS: undefined }}
455
+ />
456
+ ```
457
+
458
+ - **`execArgv`** comes after the flags the pane inherits, so a dev loader
459
+ still follows it in and a flag it names is the one in force where the two
460
+ disagree. `--max-old-space-size` is the one a pane wants most: it bounds
461
+ the pane's heap, which nothing running inside the pane can, and a pane
462
+ that passes it is ended — and its `fallback` shown — rather than the
463
+ machine swapping. The flags go to the runtime that forks the pane, and are
464
+ its flags: Bun 1.4 takes node's V8 flags and bounds nothing by them, so
465
+ under Bun this is not a heap bound.
466
+ - **`env`** is merged over this process's environment, and a variable named
467
+ with `undefined` is taken away. `DISPLAY` is `display`'s, and
468
+ `REACT_X11_FRAME` is how a pane knows it is one (`isFramed`), so the two
469
+ are set after it and cannot be overridden here.
470
+
471
+ Either is read by value: a pane started with the same flags in a new array
472
+ — one written in place, as above, at every render — is the pane it was, and
473
+ flags or variables that say something else restart it, as a new `src` does.
474
+ Something a fork cannot take — a flag that is not a string — fails as a
475
+ `spawn` error and starts nothing.
476
+
477
+ ## A pane that stops answering
478
+
479
+ A pane whose event loop is stuck — a loop that never ends, a page's script
480
+ with no timeout around it — stays `running` as far as anything else can
481
+ tell: it is sent updates it never reads, its window stays up showing its
482
+ last frame, and on X11 it holds the keyboard while the pointer is over it.
483
+ Nothing about it fails, so nothing says so.
484
+
485
+ So the host asks. About once a second it sends the pane a `ping`, and the
486
+ pane answers from its event loop, which is the thing asked about. A pane
487
+ that has not answered for `watchdog` ms — 15 seconds by default — has
488
+ stopped, and goes the way a crashed one goes: the frame fails with phase
489
+ `unresponsive`, `fallback` is shown and can `restart()` it, and the process
490
+ is ended from outside, SIGTERM and then SIGKILL, since it cannot read an
491
+ unmount. `onExit` reports it with `expected: false`.
492
+
493
+ ```jsx
494
+ <Frame
495
+ src={new URL('./page.js', import.meta.url)}
496
+ watchdog={10000}
497
+ fallback={({ error, restart }) =>
498
+ error?.phase === 'unresponsive' ? (
499
+ <PageStopped onReload={restart} />
500
+ ) : (
501
+ <PageCrashed error={error} onReload={restart} />
502
+ )
503
+ }
504
+ />
505
+ ```
506
+
507
+ Three things keep it from ending a pane that is fine:
508
+
509
+ - **The host's own late tick is no news of the pane's.** Where the host's
510
+ timer comes late — the machine slept, or the host itself was stuck — the
511
+ count starts again from that tick, rather than taking the time nobody
512
+ was asking for as time the pane did not answer.
513
+ - **A pane under the inspector is not watched.** `--inspect`,
514
+ `--inspect-brk` or `--inspect-wait`, in its own `execArgv`, in this
515
+ process's that it inherits, or in `NODE_OPTIONS`: a debugger stops a pane
516
+ on purpose. The flags that only set the inspector up, which Node's test
517
+ runner hands every test process, are not it.
518
+ - **`watchdog={false}`** watches nothing, for a pane whose work is long
519
+ and synchronous by design.
520
+
521
+ The ping and the pong are messages of the protocol, so a pane of an older
522
+ react-x11 fails the handshake, which says why, rather than being ended for
523
+ not answering a question it does not know.
524
+
525
+ ## What a pane costs
526
+
527
+ A pane is a node process with React and ntk loaded: roughly 40–70MB and
528
+ 100–300ms of module graph before its first frame, plus an X connection.
529
+ That is the price of an event loop of one's own — right for a handful of
530
+ heavy panes, wrong as a general componentization strategy. A hundred
531
+ buttons belong in your process; the log viewer that parses gigabytes does
532
+ not.
533
+
534
+ ## The transport seam
535
+
536
+ Everything between the host and the pane is eight message types over an
537
+ injectable transport, and `transport` replaces the fork wholesale — it is
538
+ how the tests run a real pane (module, root, window, props, callbacks,
539
+ close handlers) in-process over a loopback pair against the in-process X
540
+ server, with `structuredClone` standing in for the fork's serialization
541
+ (`test/helpers/frame-loopback.js`, and `runFrameChild` in
542
+ `src/frame/childmain.js` is the pane's whole bootstrap behind the same
543
+ seam). It is also the door to running a pane somewhere other than a child
544
+ process without `<Frame>` learning about it.
545
+
546
+ A transport is handed what the fork would be — `{ src, display, execArgv,
547
+ env }` — and a transport that still forks the pane, its own way (under a
548
+ sandbox, with another runtime), forks the entry the default one does, which
549
+ is on the exports map to be resolved: `fileURLToPath(import.meta.resolve(
550
+ 'react-x11/frame/child'))`. It is a process's entry, not a module to import:
551
+ outside a fork it says so and exits.
552
+
553
+ ## Known gaps
554
+
555
+ Named so they are not rediscovered:
556
+
557
+ - **Accessibility**: a pane registers on the AT-SPI bus as its own
558
+ application. Orca reads it, and Tab crosses the boundary, but the tree is
559
+ not unified under the host's.
560
+ - **DevTools**: a pane is its own React tree; `REACT_X11_DEVTOOLS=1` in the
561
+ pane process attaches a separate DevTools, not a merged one.
562
+ - **No intrinsic size**: the host decides the rect; a `measure` protocol
563
+ could exist and does not yet.
564
+ - **A window a pane opens in its first commit is placed against a guess.**
565
+ The host has not laid the pane out yet, so the pane does not know where
566
+ it is. A popup anchored to a node is placed again when the first
567
+ `pane-rect` comes, before anyone sees it; a `<Dialog>` open from the start
568
+ is centred where the pane guessed it was, as it is not anchored.
569
+ - **Windows** panes do not read `screen` yet: a popup a Windows pane
570
+ anchors is placed against the screen's corner, the second half of what
571
+ #824 fixed on Cocoa. Not run there.
572
+ - **A pane hears its window's focus, not its frame's.** Tab out of a pane
573
+ takes its focus with it, but a click on the host does not: the stop the
574
+ pane had focused keeps its focus, and its ring, while the host's is
575
+ elsewhere. On X11 the `<window embeddable>` underneath (created unmapped,
576
+ waiting for an embedder — see [embedding.md](embedding.md)) speaks
577
+ XEmbed's Tab and no more of its focus handshake — `FOCUS_CURRENT`,
578
+ `FOCUS_OUT` and window activation are left to the forwarding rules above —
579
+ and on Cocoa and Windows the pane is sent the host window's `focus` and
580
+ `blur` alone.