react-x11 2.51.1 → 2.53.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 (121) hide show
  1. package/README.md +94 -61
  2. package/docs/README.md +256 -0
  3. package/docs/accessibility.md +575 -0
  4. package/docs/appearance.md +317 -0
  5. package/docs/click-to-component.md +92 -0
  6. package/docs/clipboard.md +262 -0
  7. package/docs/components.md +1231 -0
  8. package/docs/dbus.md +205 -0
  9. package/docs/debugging.md +371 -0
  10. package/docs/desktop-calendar.md +247 -0
  11. package/docs/desktop.md +523 -0
  12. package/docs/devtools.md +133 -0
  13. package/docs/drag-and-drop.md +585 -0
  14. package/docs/ecosystem/animation.md +360 -0
  15. package/docs/ecosystem/data-fetching.md +186 -0
  16. package/docs/ecosystem/dev-tooling.md +393 -0
  17. package/docs/ecosystem/forms.md +277 -0
  18. package/docs/ecosystem/headless.md +194 -0
  19. package/docs/ecosystem/i18n.md +86 -0
  20. package/docs/ecosystem/icons.md +377 -0
  21. package/docs/ecosystem/layout.md +206 -0
  22. package/docs/ecosystem/routing.md +135 -0
  23. package/docs/ecosystem/state.md +344 -0
  24. package/docs/ecosystem/testing.md +474 -0
  25. package/docs/ecosystem/theming.md +494 -0
  26. package/docs/ecosystem.md +439 -0
  27. package/docs/elements.md +2079 -0
  28. package/docs/embedding.md +239 -0
  29. package/docs/events.md +742 -0
  30. package/docs/extending.md +1700 -0
  31. package/docs/eyedropper.md +164 -0
  32. package/docs/filedialog.md +222 -0
  33. package/docs/frame.md +436 -0
  34. package/docs/gl.md +173 -0
  35. package/docs/globalmenu.md +195 -0
  36. package/docs/macos.md +592 -0
  37. package/docs/notifications.md +150 -0
  38. package/docs/packaging.md +468 -0
  39. package/docs/permissions.md +143 -0
  40. package/docs/react-features.md +405 -0
  41. package/docs/remote.md +179 -0
  42. package/docs/scale.md +225 -0
  43. package/docs/security.md +132 -0
  44. package/docs/styling.md +1710 -0
  45. package/docs/system.md +519 -0
  46. package/docs/testing.md +494 -0
  47. package/docs/typescript.md +75 -0
  48. package/docs/uri-schemes.md +340 -0
  49. package/docs/wayland-backend.md +332 -0
  50. package/docs/windows-integrations.md +177 -0
  51. package/docs/windows.md +380 -0
  52. package/package.json +11 -3
  53. package/src/Reconciler.js +2 -2
  54. package/src/a11y.js +1 -1
  55. package/src/backend/context2d.js +3 -3
  56. package/src/bootstrap.js +1 -1
  57. package/src/bus.js +39 -26
  58. package/src/cli/create.d.ts +43 -0
  59. package/src/cli/create.js +305 -0
  60. package/src/cli/env.js +484 -0
  61. package/src/cli/index.js +29 -2
  62. package/src/cli/templates/AGENTS.template.md +195 -0
  63. package/src/cli/templates/DESIGN.template.md +269 -0
  64. package/src/cocoa/app.js +6 -6
  65. package/src/cocoa/bezels.js +1 -1
  66. package/src/cocoa/fonts.js +1 -1
  67. package/src/cocoa/globalmenu.js +1 -1
  68. package/src/cocoa/keymap.js +1 -1
  69. package/src/cocoa/panewindow.js +1 -1
  70. package/src/cocoa/player.js +1 -1
  71. package/src/cocoa/presenter.js +2 -2
  72. package/src/cocoa/promotion.js +1 -1
  73. package/src/cocoa/relaunch.js +1 -1
  74. package/src/cocoa/threaded.js +1 -1
  75. package/src/cocoa/video.js +1 -1
  76. package/src/cocoa/window.js +4 -4
  77. package/src/components/Menu.js +7 -2
  78. package/src/components/Switch.js +1 -1
  79. package/src/components/native.js +1 -1
  80. package/src/desktopintegration.js +2 -1
  81. package/src/desktopsettings.js +193 -24
  82. package/src/dnd.js +2 -2
  83. package/src/filedialog.js +3 -3
  84. package/src/glnodes.js +1 -1
  85. package/src/grid.js +1 -1
  86. package/src/keyboardstate.js +28 -0
  87. package/src/layouts.js +1 -1
  88. package/src/node.d.ts +10 -10
  89. package/src/nodes/animation.js +1 -1
  90. package/src/nodes/invalidate.js +1 -1
  91. package/src/nodes/layouthost.js +1 -1
  92. package/src/nodes/video.js +19 -8
  93. package/src/nodes/window/droptarget.js +1 -1
  94. package/src/pacing.js +1 -1
  95. package/src/portal.js +31 -0
  96. package/src/refresh/index.js +2 -1
  97. package/src/screencolor.js +5 -10
  98. package/src/types/system.d.ts +8 -6
  99. package/src/types/video.d.ts +5 -2
  100. package/src/videobackend.js +28 -0
  101. package/src/videoframes.js +29 -16
  102. package/src/wayland/app.js +86 -0
  103. package/src/wayland/clipboard.js +14 -7
  104. package/src/wayland/connection.js +3 -0
  105. package/src/wayland/context2d.js +129 -10
  106. package/src/wayland/protocols/index.json +6 -0
  107. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  108. package/src/wayland/seat.js +28 -1
  109. package/src/wayland/swapchain.js +23 -0
  110. package/src/wayland/video.js +376 -0
  111. package/src/wayland/xkb.js +137 -0
  112. package/src/win32/a11y.js +1 -1
  113. package/src/win32/app.js +2 -2
  114. package/src/win32/fonts.js +1 -1
  115. package/src/win32/glarea.js +4 -4
  116. package/src/win32/overlay.js +1 -1
  117. package/src/win32/panewindow.js +3 -3
  118. package/src/win32/shell.js +1 -1
  119. package/src/win32/window.js +2 -2
  120. package/src/windowid.js +1 -1
  121. package/src/xvideo.js +189 -0
@@ -0,0 +1,143 @@
1
+ # Permissions
2
+
3
+ May this app use the camera, the microphone, the screen, the accessibility
4
+ APIs — and how does it ask?
5
+
6
+ ```jsx
7
+ import { usePermission } from 'react-x11';
8
+
9
+ function RecordButton() {
10
+ const mic = usePermission('microphone');
11
+
12
+ if (!mic.available) return <Button label="Record" onPress={record} />;
13
+ if (mic.status === 'granted')
14
+ return <Button label="Record" onPress={record} />;
15
+ return (
16
+ <Button
17
+ label={mic.status === 'prompt' ? 'Allow microphone' : 'Open Settings'}
18
+ onPress={() =>
19
+ mic.status === 'prompt' ? mic.request() : mic.openSettings()
20
+ }
21
+ />
22
+ );
23
+ }
24
+ ```
25
+
26
+ ## The ladder
27
+
28
+ Like the [file dialog](filedialog.md)'s, chosen for you — one rung that answers
29
+ and one that only points:
30
+
31
+ | | | |
32
+ | --- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | 1 | **the platform's authorization** | The [cocoa backend](macos.md): macOS's privacy (TCC) status per kind, and the system's own prompt where a framework offers one — `AVCaptureDevice` for the camera and microphone, `CoreLocation`, and the rest. |
34
+ | 2 | **the Settings pane** | `open x-apple.systempreferences:…` — any Mac, bridge or not. It cannot read a status or raise a prompt; it puts the user in front of the switch, which is all `openPrivacySettings` promises. |
35
+
36
+ **On Linux nothing answers yet** (the per-device portals,
37
+ `org.freedesktop.portal.Camera`, `.Location`, …, are not wired up). A status
38
+ query says `'unknown'` and a request rejects with a **typed**
39
+ `NoPermissionServiceError` — the signal to keep the feature behind the answer,
40
+ not a crash. `usePermission().available` is that branch as render state.
41
+
42
+ ## The vocabulary
43
+
44
+ ```ts
45
+ type PermissionKind =
46
+ | 'camera'
47
+ | 'microphone'
48
+ | 'screen-recording'
49
+ | 'accessibility'
50
+ | 'input-monitoring'
51
+ | 'automation'
52
+ | 'location'
53
+ | 'calendars'
54
+ | 'reminders';
55
+
56
+ type PermissionStatus =
57
+ 'granted' | 'denied' | 'restricted' | 'prompt' | 'write-only' | 'unknown';
58
+ ```
59
+
60
+ A status is one of six words, and two of them are not the platform's:
61
+
62
+ | | |
63
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `'granted'` | use it |
65
+ | `'denied'` | the user said no; `openPrivacySettings()` is the way back |
66
+ | `'restricted'` | MDM or parental controls — the user **cannot** grant it, so no Settings button |
67
+ | `'prompt'` | not decided yet — `request()` would ask |
68
+ | `'write-only'` | macOS 14's partial grant for `calendars` and `reminders`: the app may **save** an item it cannot read. A grant to a writer and a refusal to a reader, so it crosses as its own word rather than being flattened into one of them |
69
+ | `'unknown'` | nothing here can say. A fact about the machine, not the permission, and the reason a status query never throws |
70
+
71
+ A request answers with the **status after the user answered**, never a bare
72
+ boolean, because `'denied'` and `'restricted'` want different UI.
73
+
74
+ ## `usePermission()`
75
+
76
+ ```ts
77
+ const { status, available, request, refresh, openSettings } = usePermission(kind, { target? });
78
+ ```
79
+
80
+ `status` starts `'unknown'` and settles once read. `request()` resolves with
81
+ the status after the user answered and updates `status` with it; a second call
82
+ while one is in flight returns the same promise, so a double-clicked button
83
+ asks once. There is **no change notification** behind a status on any
84
+ platform — the status is what was last read, on mount and after each
85
+ `request()`; `refresh()` re-reads it on your own cue, a window coming back to
86
+ the front being the usual one.
87
+
88
+ ## The bare functions
89
+
90
+ ```ts
91
+ import {
92
+ permissionStatus,
93
+ requestPermission,
94
+ openPrivacySettings,
95
+ permissionBackend,
96
+ } from 'react-x11';
97
+
98
+ await permissionStatus('camera'); // never rejects for anything about the machine
99
+ await requestPermission('camera'); // rejects with NoPermissionServiceError where nothing can ask
100
+ await openPrivacySettings('camera'); // true if something opened; false off macOS
101
+ permissionBackend(); // 'cocoa' | null, synchronously
102
+ ```
103
+
104
+ ## What each kind actually does on macOS
105
+
106
+ Not every kind has a prompt of its own, and the shape of the request follows
107
+ the framework's:
108
+
109
+ | kind | status | the "prompt" |
110
+ | ---------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
111
+ | `camera`, `microphone` | all five | the system's in-process dialog; the answer arrives when the user clicks |
112
+ | `screen-recording` | never `'prompt'` | the system's go-to-Settings dialog, shown once; the request resolves at once, and a new grant needs a **restart** |
113
+ | `accessibility` | never `'prompt'` | the go-to-Settings dialog; resolves at once, and the grant applies live |
114
+ | `input-monitoring` | `'prompt'` while open | posts the prompt and resolves at once |
115
+ | `automation` | per **target** | `{ target: 'com.apple.finder' }` — the bundle id of a **running** app, or the call throws; asking blocks until answered |
116
+ | `location` | all five | `requestWhenInUseAuthorization`, answered through the run loop |
117
+ | `calendars` | plus `'write-only'` | EventKit's own prompt. `{ access: 'write-only' }` asks the narrower one; the grant is what [desktop-calendar.md](desktop-calendar.md) reads through |
118
+ | `reminders` | plus `'write-only'` | the same store, a separate grant. `{ access: 'write-only' }` is a `TypeError` here: reminders have no such grant |
119
+ | `files-and-folders` | — | no API: reading the folder _is_ the prompt and `EPERM` the denial; `openPrivacySettings` reaches the pane |
120
+ | `full-disk-access` | — | no API; `openPrivacySettings('full-disk-access')` |
121
+
122
+ **Attribution is the thing to know.** A bare `node` process is attributed to
123
+ its _responsible process_ — the terminal, an IDE — or to `node` itself, and
124
+ prompts with no usage-description strings. A bundled app must carry the keys
125
+ (`NSCameraUsageDescription`, `NSMicrophoneUsageDescription`,
126
+ `NSLocationUsageDescription`, `NSAppleEventsUsageDescription`,
127
+ `NSCalendarsFullAccessUsageDescription`,
128
+ `NSCalendarsWriteOnlyAccessUsageDescription`,
129
+ `NSRemindersFullAccessUsageDescription`) in its `Info.plist` — without them
130
+ the request never prompts, or TCC ends the process. See
131
+ [packaging.md](packaging.md).
132
+
133
+ ## What differs between the rungs
134
+
135
+ - **The Settings rung reads nothing.** `openPrivacySettings` opens the pane
136
+ on any Mac — through the bridge on the cocoa backend, by `open` under
137
+ XQuartz — and that is all it does; the status stays `'unknown'` on the X11
138
+ backend because nothing there can read TCC.
139
+ - **`'unknown'` is not `'prompt'`.** An app that treats them alike will show
140
+ an "Allow" button on Linux that rejects when pressed. Branch on `available`
141
+ first.
142
+
143
+ Design notes: [design/permissions.md](../design/permissions.md).
@@ -0,0 +1,405 @@
1
+ # React features
2
+
3
+ **Everything in React core behaves as documented** — hooks, context, error
4
+ boundaries, `<Suspense>`, `lazy`, `use`, transitions, `<Profiler>`,
5
+ `<StrictMode>`. react-x11 is a normal React 19 renderer, and where React's
6
+ behaviour does not depend on the DOM, it is the same behaviour. This page is
7
+ the caveats, and the four places where a DOM habit quietly does not work.
8
+
9
+ The examples that exercise it:
10
+
11
+ ```sh
12
+ npm run examples:monitor # priority: a filter over ~780 live rows
13
+ npm run examples:chat # <Activity>, useOptimistic, <Suspense>
14
+ npm run examples:timer # error boundaries, and where a crash is logged
15
+ ```
16
+
17
+ What differs is anything the DOM used to define for you:
18
+
19
+ - **Paint timing.** A commit does not put pixels on screen. Frames go out on
20
+ a clock, so "before paint" is a real, observable boundary.
21
+ - **Measuring.** There is no `getBoundingClientRect()` that forces layout.
22
+ Layout has already happened, or it has not.
23
+ - **Hiding.** `<Suspense>` and `<Activity>` hide a `<box>` cheaply, but
24
+ hiding a `<window>` genuinely unmaps it, and the window manager notices.
25
+ - **Portal containers.** There is no document to portal into.
26
+
27
+ ## At a glance
28
+
29
+ | | |
30
+ | ----------------------------------------------------------------- | ----------------------------------------------------------------- |
31
+ | `useState`, `useReducer`, `useContext`, `useRef` | unchanged |
32
+ | `useMemo`, `useCallback`, custom hooks | unchanged |
33
+ | `useImperativeHandle`, `forwardRef`, `ref` as a prop, ref cleanup | unchanged |
34
+ | `use(promise)`, `use(context)`, `React.lazy` | unchanged |
35
+ | `useOptimistic`, `useActionState`, `<Profiler>` | unchanged |
36
+ | `useSyncExternalStore` | unchanged, and the best way to bridge non-React code |
37
+ | `useTransition`, `startTransition`, `useDeferredValue` | work, and really do stay responsive |
38
+ | `useLayoutEffect` vs `useEffect` | the difference is real — one paint versus two |
39
+ | `<Suspense>` | works; [around a `<window>`](#suspense-and-activity) it unmaps it |
40
+ | `<Activity>` | works; `mode="hidden"` around a `<window>` unmaps it |
41
+ | Error boundaries | work — but **where you put one** decides if the window survives |
42
+ | `<StrictMode>` | safe; effects are not double-invoked on the first mount |
43
+ | `useId` | works; there is very little here to use it for |
44
+ | `createPortal` | use [`<popup>`](elements.md) instead — see [Portals](#portals) |
45
+ | `flushSync` | not exported; you almost certainly do not need it |
46
+ | `useFormStatus` | not wired up |
47
+ | React Server Components | not available |
48
+ | anything imported from `react-dom` | not available — see [Not available](#not-available) |
49
+
50
+ ## Effects, and the paint boundary
51
+
52
+ Put an effect that **changes what the user sees** in `useLayoutEffect`. Put
53
+ everything else in `useEffect`. That is the same rule as the DOM, and here
54
+ it has a directly observable consequence:
55
+
56
+ | you call `setState` from | what the user sees |
57
+ | ------------------------ | ------------------------------------------------------- |
58
+ | `useLayoutEffect` | one frame, with the corrected value |
59
+ | `useEffect` | one frame with the old value, then a frame with the new |
60
+
61
+ So an adjustment made in `useEffect` flickers, every time: updates from a
62
+ layout effect are folded into the frame being prepared, and updates from a
63
+ passive effect are not.
64
+
65
+ That holds for a correction computed from React state. One computed from
66
+ **geometry** is a layout behind: a layout effect runs after the commit and
67
+ before the layout pass, so `abs` there is still the previous frame's rect
68
+ (zeros, on mount). For a size, let the element report it with `onLayout`
69
+ ([below](#measuring-a-node)); for a style that depends on one, write a
70
+ container query ([styling.md](styling.md#container-queries)), which the
71
+ pass answers in the same frame. `scrollIntoView` and `scrollTo` — including
72
+ `scrollTo(Infinity)` to follow rows this commit added — are safe here: the
73
+ pass answers them against the geometry it produces
74
+ ([elements.md](elements.md#scrolling)).
75
+
76
+ The same boundary is why an event handler feels instant: the React update
77
+ caused by a click or a keystroke lands **before** the frame goes out, so the
78
+ response and the default action paint together.
79
+
80
+ ```jsx
81
+ // good — `tooTall` and `fits` are React state, so the corrected rows paint
82
+ // in the same frame as the state that asked for them
83
+ useLayoutEffect(() => {
84
+ if (tooTall) setRows(fits);
85
+ }, [tooTall, fits]);
86
+ ```
87
+
88
+ ## Measuring a node
89
+
90
+ **There is no forced synchronous layout.** Nothing you can call will make
91
+ layout run so you can read the result, as `getBoundingClientRect()` does in a
92
+ browser.
93
+
94
+ A node's `abs` rect (and `getClientRects()`) reports what the **last** layout
95
+ produced. On the very first render of a node, that is nothing yet.
96
+
97
+ What to do instead:
98
+
99
+ - **Let the element tell you.** `onLayout` fires on any element after a
100
+ layout pass moved or resized it, with `width`/`height` (and `x`/`y`
101
+ within the parent) in logical pixels — once after the first layout, then
102
+ on change. `<box onViewport>` adds a scroller's content size, and
103
+ `<window onResize>` fires when the window is resized. These are the
104
+ supported way to react to a size.
105
+ - **Read `abs` from a ref in a handler or a later effect**, not during the
106
+ render that created the node.
107
+ - **Take a getter, not a value.** `useWindowId(ref)` and `useAnchor()` return
108
+ getters for exactly this reason — a value captured during render would be
109
+ `null` on the render where you needed it.
110
+
111
+ ```jsx
112
+ const [cols, setCols] = useState(1);
113
+
114
+ <box onLayout={({ width }) => setCols(Math.max(1, (width / 200) | 0))}>
115
+ {items.map(…)}
116
+ </box>;
117
+ ```
118
+
119
+ When what depends on the size is a _style_ rather than a decision about what
120
+ to render, write it as a container query block instead
121
+ ([styling.md](styling.md#container-queries)): it applies in the same frame,
122
+ where a state set from `onLayout` re-renders a frame later.
123
+
124
+ If you are porting a component that measures itself and adjusts, this is the
125
+ part to rewrite. See [elements.md](elements.md) for `onLayout`, `onViewport`
126
+ and `onResize`, and [components.md](components.md) for `useAnchor`.
127
+
128
+ ## What a ref gives you
129
+
130
+ Two different kinds of object, depending on the element:
131
+
132
+ - **`<window>` and `<popup>`** hand back the live **window object** of
133
+ whichever backend is drawing — on X11 the ntk window, which is what
134
+ [`windowIdOf()`](elements.md) resolves to an XID. Both spell
135
+ `getContext('2d')` and `requestAnimationFrame` the same way; anything
136
+ past that is the backend's own surface, so a component that reaches
137
+ further should say which backend it is for.
138
+ - **every other element** hands back its **node**: `focus()`, `blur()`,
139
+ `focused`, `hitTest()`, `containsPoint()`, `getClientRects()`, and `abs`
140
+ for its position and size. `<textinput>`/`<textarea>` additionally match
141
+ the DOM closely enough that libraries like react-hook-form drive them
142
+ through a ref.
143
+
144
+ A window ref is `null` until that window exists, and refs attach after the
145
+ commit — so read them in effects and handlers, never during render.
146
+
147
+ For `<canvas>`, note that the **node is not a drawing surface**. There is no
148
+ `getContext()` on it; you draw in `onDraw`, which the renderer calls with a
149
+ context whenever the canvas needs repainting:
150
+
151
+ ```jsx
152
+ <canvas onDraw={(ctx, { width, height }) => { …}} />
153
+ ```
154
+
155
+ To make it repaint, change something React can see — a prop, state, or the
156
+ `cacheKey`. That is the supported path, and it is usually what you want.
157
+ There is an imperative escape hatch for element authors in
158
+ [extending.md](extending.md), but note that it lives on the **owning
159
+ window**, not on the drawn node, despite what the bundled types currently
160
+ say (see [Known gaps](#known-gaps)).
161
+
162
+ ## Transitions, and what stays responsive
163
+
164
+ `useTransition`, `startTransition` and `useDeferredValue` all work, and they
165
+ really do keep the UI responsive — React will interrupt a transition render
166
+ to handle an incoming click or keystroke.
167
+
168
+ **Transitions are the only mechanism that does this.** An ordinary
169
+ `setState` — from a handler, a timer, a socket callback — renders in one
170
+ uninterruptible pass, however much work it is. If a render is big enough to
171
+ drop a frame, `startTransition` (or `useDeferredValue` on its input) is the
172
+ difference between a responsive window and a frozen one.
173
+
174
+ ```jsx
175
+ const [pending, startTransition] = useTransition();
176
+
177
+ // typing stays instant; the expensive list catches up
178
+ const onChange = (e) => {
179
+ setQuery(e.value);
180
+ startTransition(() => setResults(search(e.value)));
181
+ };
182
+ ```
183
+
184
+ Two smaller notes:
185
+
186
+ - `root.render()` is **synchronous** — the tree is committed before the call
187
+ returns. Wrapping it in `startTransition` does nothing.
188
+ - A `startTransition` inside a click handler is still a transition. It does
189
+ not inherit the handler's urgency.
190
+
191
+ Input is already classified for you: presses, releases, keys, focus
192
+ changes, window close and drops are urgent; pointer motion and drag-over are
193
+ not, which keeps a motion burst from flooding the renderer.
194
+
195
+ [`examples/monitor.jsx`](../examples/monitor.jsx) is the worked case: a
196
+ filter over hundreds of live rows, where one keystroke changes every row on
197
+ screen. Two things there are worth copying: the expensive subtree is
198
+ `memo`ised (or the urgent render rebuilds it anyway, and deferring buys
199
+ nothing), and no prop that changes on every update — not even a "stale"
200
+ border — is passed into it.
201
+
202
+ ### Bridging non-React code
203
+
204
+ `useSyncExternalStore` is the right tool for anything that lives outside
205
+ React — a timer, a D-Bus signal, a socket, an ntk event — and faster than a
206
+ hand-rolled `setState` bridge: a store notification is applied urgently, in
207
+ the next microtask, where the equivalent `setState` waits a turn longer.
208
+ Every mainstream store (zustand, jotai, valtio, redux, XState) works with no
209
+ adapter. The [window-manager example](../examples/wm.jsx) is the worked
210
+ case: the WM core is a plain store, and the UI subscribes to it.
211
+
212
+ ## Suspense and Activity
213
+
214
+ `<Suspense>` works. Plan for layout: a hidden subtree gives up its space
215
+ entirely, so **size your fallback deliberately**, or the window collapses to
216
+ the fallback and jumps back on reveal.
217
+
218
+ ```jsx
219
+ <Suspense fallback={<box style={{ height: 240 }} />}>
220
+ <Report />
221
+ </Suspense>
222
+ ```
223
+
224
+ **Focus goes with the hidden tree and comes back with it.** A field inside
225
+ the boundary gives up the keyboard when the fallback appears — nothing
226
+ invisible collects keystrokes — and gets it back on the reveal unless
227
+ something else took focus meanwhile, so a boundary that re-suspends
228
+ mid-edit does not drop the user out of what they were typing. See
229
+ [events.md](events.md#focus-and-visibility) for the rules and the way out.
230
+
231
+ **Scroll positions come back too.** A hidden boundary's content is not laid
232
+ out while hidden (as under `display: 'none'`), so a scroll pane in it is not
233
+ clamped, reports no `onViewport` or `onScroll` until shown, and reappears at
234
+ the offset it was left at — a tab kept under `<Activity>` reopens on the row
235
+ the user was reading.
236
+
237
+ If a `<window>` is inside the boundary, hiding it **unmaps the real window**.
238
+ The window manager treats the reveal as a new window: it may re-place it,
239
+ restack it, or apply its own geometry, and anything the user did to it can be
240
+ lost. Prefer suspending _inside_ a window over suspending the window itself.
241
+
242
+ `React.lazy` works. If you ship a single-file bundle, note that dynamic
243
+ `import()` gets inlined — you keep lazy _evaluation_ but get no code
244
+ splitting; see [packaging.md](packaging.md).
245
+
246
+ `<Activity>` works, and `mode="hidden"` around a toplevel `<window>` unmaps
247
+ it the same way; one mounted hidden is created and laid out but never
248
+ mapped until the reveal. The same caveat applies: what the user did to the
249
+ window before it was hidden is the window manager's to remember, and it may
250
+ not.
251
+
252
+ **Testing Suspense**: a promise that settles outside `act()` is not always
253
+ picked up by an `await act()`, because React throttles the commit when a
254
+ fallback was shown very recently. Use `waitFor` from `react-x11/test` —
255
+ see [testing.md](testing.md).
256
+
257
+ ## Portals
258
+
259
+ **Use `<popup>`** where the DOM would reach for `createPortal`. A `<popup>`
260
+ is an ordinary child in your JSX — it sees context, state and props where
261
+ you wrote it — but a real top-level window, so it escapes its parent's
262
+ clipping and can extend past the window edge.
263
+
264
+ ```jsx
265
+ <box>
266
+ <Button onClick={(e) => setAt({ x: e.screenX, y: e.screenY })}>
267
+ Options…
268
+ </Button>
269
+ {at && (
270
+ <popup
271
+ x={at.x}
272
+ y={at.y}
273
+ width={180}
274
+ height={120}
275
+ grab
276
+ onDismiss={() => setAt(null)}
277
+ >
278
+ <Menu />
279
+ </popup>
280
+ )}
281
+ </box>
282
+ ```
283
+
284
+ Its position in the JSX has no effect on where it appears — you place it in
285
+ screen coordinates, and `useAnchor()` does that math for you when you are
286
+ anchoring to another node.
287
+
288
+ `Dialog`, `Tooltip`, `ContextMenu`, `MenuBar` and `Select` are all built this
289
+ way — see [components.md](components.md).
290
+
291
+ There is no `createPortal` export. The reconciler's own is reachable through
292
+ the `Renderer` escape hatch, but it is not a supported surface: the container
293
+ has to be an X connection or an X window rather than a node, and only
294
+ `<window>`/`<popup>` can be the portal's immediate child. If you find
295
+ yourself wanting it, you almost certainly want a second `<window>` or a
296
+ `<popup>`.
297
+
298
+ ## Where to put an error boundary
299
+
300
+ Boundaries work exactly as documented. The decision specific to a desktop
301
+ renderer is **placement**, and it decides whether the user's window
302
+ survives:
303
+
304
+ ```jsx
305
+ // good — the window and everything the WM knows about it survives
306
+ <window title="Editor">
307
+ <ErrorBoundary fallback={<text>Something broke</text>}>
308
+ <Document />
309
+ </ErrorBoundary>
310
+ </window>
311
+
312
+ // the window itself is destroyed and recreated on a throw
313
+ <ErrorBoundary fallback={<window title="Editor">…</window>}>
314
+ <window title="Editor">
315
+ <Document />
316
+ </window>
317
+ </ErrorBoundary>
318
+ ```
319
+
320
+ A recreated window is a **new** window: its position, size, stacking,
321
+ maximized state and anything else the user or the window manager did to it
322
+ are gone. Put the boundary inside the window unless you genuinely want the
323
+ window replaced.
324
+
325
+ An **uncaught** error unmounts the whole tree, which here means every window
326
+ disappears while the process stays alive. `createRoot` takes
327
+ `onUncaughtError`, `onCaughtError` and `onRecoverableError` so you can decide
328
+ what that should mean for your app — the default logs and sets a failing exit
329
+ code. See [events.md](events.md).
330
+
331
+ One thing React does not cover: **a throw inside an event handler never
332
+ reaches a boundary.** (That is true in React DOM too.) react-x11 routes those
333
+ to `onUncaughtError` instead, so they are at least reportable rather than
334
+ silent.
335
+
336
+ ## Smaller notes
337
+
338
+ **`useId`** works, but there is little use for it: no element takes an `id`,
339
+ there is no hydration, and labels are associated structurally (`<Checkbox
340
+ label>`, `<Radio>children</Radio>`). If you want a window's real identity,
341
+ that is `useWindowId()`. Avoid putting an `id` prop on `<window>` — unknown
342
+ props there are forwarded to the underlying window as creation attributes.
343
+
344
+ **`<StrictMode>`** is safe. A discarded render costs no X11 traffic, so
345
+ double-rendering never produces double windows. Be aware that effects are
346
+ **not** double-invoked on the initial mount, so it will not catch a missing
347
+ cleanup at mount the way it does in React DOM.
348
+
349
+ **`memo` / `useMemo`** save CPU, not bandwidth. Rebuilding a `style` object
350
+ or array every render is free — styles are compared by value, so an
351
+ identical style produces no layout work, no repaint and no frame. Passing
352
+ `style={[base, active && on]}` inline is the documented idiom, not a leak.
353
+ See [styling.md](styling.md).
354
+
355
+ **Keys** matter more for `<window>` children than for drawn ones. Reordering
356
+ `<box>` children is cheap. Reordering sibling `<window>`s restacks real
357
+ windows, and a missing key destroys and recreates them — with the same loss
358
+ of window-manager state described above. To keep a window on top, use
359
+ `alwaysOnTop` rather than ordering.
360
+
361
+ **DevTools** works — set `REACT_X11_DEVTOOLS=1` and the component tree,
362
+ props and highlight-on-hover all behave normally, with the highlight drawn
363
+ into the real window. The Profiler **Timeline** tab is unavailable; the
364
+ commit list and flamegraph work. See [devtools.md](devtools.md).
365
+
366
+ **Fast Refresh** works without a bundler: `npx react-x11 dev app.jsx` runs
367
+ a file with its default export mounted and every save applied in place, over
368
+ the `react-x11/refresh` entry point, which a tool that hosts the loader
369
+ itself registers with `--import`. Keep anything
370
+ whose identity must survive a reload — contexts, stores — in a module you
371
+ are not editing. See [dev tooling](ecosystem/dev-tooling.md#react-refresh)
372
+ and the [hot-reload example](../examples/tasks.jsx), which is
373
+ `npm run examples:tasks:hot`.
374
+
375
+ ## Not available
376
+
377
+ **`react-dom`.** It is not a dependency, so `ReactDOM.createPortal`,
378
+ `flushSync`, `findDOMNode`, `hydrateRoot` and `unstable_batchedUpdates` are
379
+ not part of react-x11.
380
+
381
+ The trap: `react-dom` is a required peer of some libraries, so it can end up
382
+ installed anyway. Then the import resolves, and `flushSync()` runs your
383
+ callback **without flushing anything**, because it talks to a renderer you
384
+ are not using. If a library depends on synchronous flushing, check
385
+ `npm ls react-dom`; the [ecosystem register](ecosystem.md) records which
386
+ packages hit this.
387
+
388
+ **Server Components** are not available: there is no Flight client wired up.
389
+
390
+ **`useFormStatus`** is not wired up. `useOptimistic` and `useActionState` are
391
+ unaffected and work normally.
392
+
393
+ ## Known gaps
394
+
395
+ Open bugs where a React feature does not yet mean here what it should:
396
+
397
+ - A `<popup>` written inside a subtree that `<Suspense>` or `<Activity>`
398
+ hides stays on screen: React hides the topmost host instance of the branch
399
+ and the popup is its own X window below it, so nothing unmaps it. Focus
400
+ follows what is actually visible, so the popup keeps the keyboard — but
401
+ the popup itself has to be conditional in your JSX rather than left to the
402
+ boundary.
403
+ - The bundled types declare `invalidate()` on every node; at runtime only the
404
+ owning `<window>` has it. Reach it as `ref.current.root` — or better, drive
405
+ repaints through props and `cacheKey`.