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,393 @@
1
+ # Developer tooling
2
+
3
+ A react-x11 app is an ordinary Node process, so V8 and React tooling apply
4
+ unmodified; nothing here needs an adapter. Which tool answers which
5
+ question:
6
+
7
+ | Question | Tool |
8
+ | ---------------------------------------------- | ------------------------------------------------ |
9
+ | Which component rendered, and with what props? | [React DevTools](#react-devtools) |
10
+ | Was that render _wasted_? | [why-did-you-render](#why-did-you-render) |
11
+ | Where did the milliseconds go? | [`node --inspect`](#node-inspect), [`0x`](#0x) |
12
+ | Why is memory growing? | [`node --inspect`](#node-inspect) heap snapshots |
13
+
14
+ `react-scan` does not work: its documented entry point does not resolve
15
+ under bare Node, and its programmatic API silently never fires
16
+ ([register](../ecosystem.md#silent-failures)). React's own `<Profiler>`
17
+ works unchanged and covers commit timings.
18
+
19
+ ## React DevTools {#react-devtools}
20
+
21
+ **Out of the box — already wired behind an env var.** react-devtools-core@7.0.1.
22
+
23
+ `react-devtools-core` is the DevTools backend for a React tree outside a
24
+ browser page: it connects over WebSocket to the standalone DevTools UI and
25
+ streams the fiber tree, props, hooks and profiler data. It costs nothing
26
+ when off.
27
+
28
+ ```sh
29
+ npm i -D react-devtools-core
30
+
31
+ # terminal 1 — standalone UI, listens on :8097
32
+ npx react-devtools
33
+
34
+ # terminal 2 — any app
35
+ REACT_X11_DEVTOOLS=1 npm run examples:dashboard
36
+ ```
37
+
38
+ `REACT_X11_DEVTOOLS` is read on the first `render()`/`createRoot()`, which
39
+ installs the DevTools hook before the first commit, so every mounted root is
40
+ visible. `REACT_X11_DEVTOOLS_HOST` and `REACT_X11_DEVTOOLS_PORT` point the
41
+ backend at a UI elsewhere — useful when the app runs on a headless box next
42
+ to the X server.
43
+
44
+ Working: the live component tree, props/hooks/state inspection, and
45
+ highlight-on-hover — hovering an element in the tree tints its rect in the
46
+ window. See [devtools](../devtools.md) for the full walkthrough.
47
+
48
+ - **Start the standalone UI first**, or wait for the backend's retry. The
49
+ app side retries; the UI does not attach to an already-running app unless
50
+ the hook was installed at startup, and `REACT_X11_DEVTOOLS` cannot be
51
+ turned on after the fact.
52
+ - The Profiler's commit-oriented views (flamegraph, ranked) work with the
53
+ dev reconciler. The **Timeline** view does not — it needs
54
+ `injectProfilingHooks`, which react-reconciler 0.33 does not expose.
55
+ - DevTools cannot map a host node back to its fiber
56
+ (`findFiberByHostInstance` is never registered); the tree, props and
57
+ highlight do not need it.
58
+
59
+ Design notes: [design/ecosystem/dev-tooling.md](../../design/ecosystem/dev-tooling.md).
60
+
61
+ ## react-refresh {#react-refresh}
62
+
63
+ **Supported — `react-x11 dev` is the command, and `react-x11/refresh` the
64
+ entry point under it.** Fast Refresh without a bundler: edit a component and
65
+ the mounted window updates in place with hook state intact — the
66
+ connection, the window, half-typed text in an input all survive.
67
+
68
+ ```sh
69
+ npx react-x11 dev app.jsx
70
+ ```
71
+
72
+ A new app comes set up for it: `npm create react-x11@latest my-app` (or
73
+ `npx react-x11 create my-app`) writes `src/App.jsx`, an entry for a plain
74
+ run, and a `dev` script, with the toolchain below already in its
75
+ devDependencies. Beside them go `DESIGN.md`, the app's design brief, and
76
+ `AGENTS.md`, which tells a coding agent how react-x11 differs from
77
+ react-dom, where the docs installed with the package are, and how to check
78
+ its work by rendering the app to a PNG in light and dark
79
+ (`--no-agents-md` leaves it out).
80
+
81
+ ### `react-x11 dev` {#react-x11-dev}
82
+
83
+ The package's `bin`, with nothing to set up. What it does that the bare
84
+ loader leaves to the app:
85
+
86
+ - **It is the entry, and imports the file.** The file named on node's
87
+ command line is the one module that never reloads (see below), so `dev`
88
+ imports yours instead, making it an ordinary hot module: an app in one
89
+ file reloads.
90
+ - **It mounts the file's default export.** A file that mounts a root of its
91
+ own while it is imported runs as it is — `dev` sees the root appear and
92
+ mounts nothing — and then the modules it imports reload while it does
93
+ not. A file that runs both ways mounts itself behind
94
+ `if (!import.meta.hot)`, which is false under `dev` and true under `node`,
95
+ `tsx` and `bun`; `examples/tasks.jsx` is written that way. One that mounts
96
+ itself unguarded _and_ exports only components is told, at start, that
97
+ every save re-runs its `createRoot` and opens another window.
98
+ - **It supplies a `<window>` while the tree renders none.** A drawn
99
+ element at the root throws an error with `code: 'NOT_IN_WINDOW'` before
100
+ anything commits, and `dev` then mounts the tree inside a window titled
101
+ after the file, sized to its content with a 320×200 floor. Decided again
102
+ on every reload: render a `<window>` and `dev`'s goes (the component
103
+ remounts, so its state resets — `dev` says so); lose it and it comes
104
+ back. A tree that renders a `<window>` _beside_ content in none stays
105
+ wrapped, with a note saying which content needs a window. Closing the
106
+ window ends the process.
107
+ - **It says when a save will not reload.** A module reloads in place only
108
+ when everything it exports is a component by React Refresh's rule — a
109
+ capitalized name — so `export default () => …` and a module exporting a
110
+ constant beside its component run, but saving them reloads nothing. `dev`
111
+ says so, and why, when it starts, instead of leaving the first save to
112
+ find out.
113
+ - **It asks before it installs the toolchain.** The four packages below are
114
+ optional peer dependencies, so an app's users never download them. `dev`
115
+ finds the missing ones, pins them to react-x11's ranges, and on a yes runs
116
+ the project's package manager (from its lockfile, else the one that
117
+ launched `dev`, else npm) and starts again. With no terminal to ask on, it
118
+ prints the command and exits.
119
+ - **It connects to [React DevTools](#react-devtools)** whenever
120
+ `npx react-devtools` is running, started before the app or after it —
121
+ wherever `react-devtools-core` is installed. Where it is not, and a
122
+ DevTools UI is listening when `dev` starts, `dev` prints the install
123
+ command. `REACT_X11_DEVTOOLS=0` turns it off.
124
+ - **It turns on [click-to-component](../click-to-component.md).**
125
+ Alt+Click (Option+Click on macOS) an element and its JSX line opens in
126
+ your editor. `REACT_X11_CLICK_TO_COMPONENT=0` turns it off.
127
+ - **It runs the project's react-x11.** A global or npx-cached `react-x11`
128
+ hands over to the copy the file imports (a second copy would be a root
129
+ nobody refreshes); a file that imports none is told to install it.
130
+
131
+ **Errors are shown in a window**, as well as printed:
132
+
133
+ - **A render error** takes the app's tree down and a window titled
134
+ `app.jsx — error` stands in for it: the message, the components it was
135
+ thrown under, the source around the line, and the stack's frames (React's,
136
+ Node's and react-x11's own counted rather than listed). Each save tries
137
+ the app again.
138
+ - **A save that does not compile** opens the same window beside the app,
139
+ which keeps running the module's last version with its state; it closes
140
+ once the file compiles, or when you close it.
141
+ - Clicking a line opens it in your editor, as
142
+ [click-to-component](../click-to-component.md) does, by
143
+ `REACT_X11_EDITOR` (`cursor` when unset).
144
+ - A throw from an event handler leaves the app running; it is only printed,
145
+ and dispatch continues.
146
+
147
+ Stack lines point at the source's own lines (`dev` turns source maps on).
148
+ Arguments after the file are the app's: `react-x11 dev app.jsx --size large`
149
+ gives the app the `process.argv` that `node app.jsx --size large` would.
150
+ `.jsx` files are compiled and hot; `.js` and `.mjs` run as Node runs them,
151
+ and the `.jsx` modules they import are hot. TypeScript is not supported.
152
+
153
+ ### Under `--import` {#refresh-import}
154
+
155
+ A tool that hosts the loader itself — a workbench, a custom dev loop —
156
+ registers it with `--import`:
157
+
158
+ ```sh
159
+ node --enable-source-maps --import react-x11/refresh/register main.jsx
160
+ ```
161
+
162
+ The entry — the file named on the command line — is **never reloaded**: a
163
+ module is watched once another module imports it, and nothing imports the
164
+ entry. So a one-file app does not reload at all, silently. Keep the entry to
165
+ mounting, and the components in modules it imports:
166
+
167
+ ```jsx
168
+ // main.jsx — written once, never edited while the app runs
169
+ import React from 'react';
170
+ import ReactX11 from 'react-x11'; // default import: see the rules below
171
+ import App from './app.jsx'; // `export default function App() { ... }`
172
+
173
+ const root = await ReactX11.createRoot();
174
+ root.render(<App />);
175
+ ```
176
+
177
+ A save that does not compile is only printed (the app keeps the module's
178
+ last version); a host that wants to show it subscribes to the loader:
179
+
180
+ ```js
181
+ import { onTransform } from 'react-x11/refresh/loader';
182
+
183
+ onTransform(({ filename, error }) => {
184
+ // error is null when filename compiled
185
+ });
186
+ ```
187
+
188
+ A tool that needs the loader's options writes its own two-line `--import`
189
+ module instead of `react-x11/refresh/register`:
190
+
191
+ ```js
192
+ // tool-register.mjs, then: node --import ./tool-register.mjs app.jsx
193
+ import { registerRefresh } from 'react-x11/refresh/loader';
194
+
195
+ await registerRefresh({
196
+ extensions: ['.jsx'], // which files are hot modules
197
+ ignore: (path) => path.includes('/stores/'), // identity that must survive
198
+ prelude: ['globalThis.__TOOL__ = true'], // injected into every hot module
199
+ });
200
+ ```
201
+
202
+ ### How it reloads {#refresh-rules}
203
+
204
+ Both routes run the same loader. A module whose exports are all components
205
+ is a **refresh boundary**: an edit to it (or to anything below it with no
206
+ boundary of its own) re-evaluates up to that module and re-renders the
207
+ edited components in place. A component whose hook signature changed
208
+ remounts alone. There are no accept handlers to write; the one optional
209
+ seam is the reload event:
210
+
211
+ ```js
212
+ import { onReload } from 'react-x11/refresh';
213
+
214
+ onReload(({ urls, refreshed }) => {
215
+ console.log(`${urls.length} module(s) reloaded`);
216
+ });
217
+ ```
218
+
219
+ Node 22.15 or newer is required (for synchronous `module.registerHooks`),
220
+ plus four optional peer dependencies, which `dev` offers to install and
221
+ `--import` expects installed:
222
+
223
+ ```sh
224
+ npm install --save-dev @babel/core @babel/plugin-transform-react-jsx react-refresh hot-module-replacement
225
+ ```
226
+
227
+ `node_modules` and react-x11's own sources are outside the hot boundary,
228
+ so React, the renderer and `react-refresh/runtime` stay singletons. A stack
229
+ through a hot module points at the source's own line and column (what
230
+ click-to-component opens) when Node runs with `--enable-source-maps`, which
231
+ `dev` turns on itself; this needs hot-module-replacement 4.0.2 or newer.
232
+
233
+ These constraints are enforced as errors:
234
+
235
+ - **A named import called at module top level inside a hot module is a
236
+ transform error.** Named imports become live bindings initialized in a
237
+ microtask, so at module scope the value is still undefined. Use the
238
+ default import at top level (`React.createContext(...)`, not
239
+ `createContext(...)`); inside components anything goes. The entry is
240
+ transformed like the rest, so a named `createRoot` import followed by a
241
+ top-level `await createRoot()` is this error: write
242
+ `ReactX11.createRoot()` there.
243
+ - **Only the classic JSX runtime.** `jsxRuntime: 'automatic'` throws: the
244
+ automatic runtime appends its `react/jsx-runtime` import to the last
245
+ import's line, which the line-oriented import rewrite breaks.
246
+ - **One statement per prelude entry.** A `prelude` entry holding two
247
+ statements or a newline throws.
248
+
249
+ Not enforced:
250
+
251
+ - Under `--import`, an edit to the entry is dropped, whatever it exports;
252
+ editing it needs a restart. An edit elsewhere reaches the tree only if a
253
+ boundary sits on its import chain; a module whose importers reach the
254
+ entry without passing a component-only module is dropped the same way.
255
+ Neither says anything — `dev` reports the second, at start, for the file
256
+ it runs.
257
+ - **Module-scope side effects re-run on every reload** of the module and of
258
+ everything between it and its boundary. Keep hot modules' top level
259
+ idempotent, and keep identity that must survive (contexts, stores) outside
260
+ the hot graph — via the `ignore` seam or by extension. `registerElement`
261
+ at module scope is safe: during hot reload a duplicate registration
262
+ replaces silently instead of throwing
263
+ ([re-registration policy](../extending.md#hot-reload)), and mounted nodes
264
+ keep the old prototype until they remount. The named-import rule applies:
265
+ call it as `Host.registerElement(...)` through a namespace or default
266
+ import.
267
+ - Fast Refresh needs the dev reconciler; `NODE_ENV=production` is a
268
+ registration error. There is no production hot path.
269
+
270
+ ## `node --inspect` {#node-inspect}
271
+
272
+ **Out of the box.** Node built-in.
273
+
274
+ `node --inspect` exposes a debug port, and Chrome's `chrome://inspect` (or
275
+ VS Code's auto-attach) connects a full DevTools instance — sourcemap-aware
276
+ CPU profiler, heap snapshots, allocation timelines, live debugging.
277
+ Everything that costs time in a react-x11 app is ordinary JS frames, so this
278
+ is the recommended profiling path:
279
+
280
+ - **CPU profiles of commits.** Reconciler frames (`performUnitOfWork`,
281
+ `completeWork`), yoga layout (`calculateLayout`), style resolution and ntk
282
+ paint/flush show up in one call tree: "is it React, layout, or painting"
283
+ in one recording.
284
+ - **Heap snapshots of retained nodes.** Every drawn element is a node object
285
+ reachable from the ntk window's `_reactX11Node`, so leaked subtrees — a
286
+ `<popup>` that never unmounted, listeners held after close — are directly
287
+ countable in the constructor list. Two snapshots plus "Objects allocated
288
+ between…" isolates a leak per interaction.
289
+
290
+ ```sh
291
+ # interactive: profile a running app from Chrome
292
+ node --inspect --import tsx examples/dashboard.jsx
293
+ # chrome://inspect -> Open dedicated DevTools for Node -> Performance / Memory
294
+
295
+ # capture-on-exit CPU profile, no UI needed
296
+ node --cpu-prof --cpu-prof-dir=./profiles --import tsx examples/dashboard.jsx
297
+
298
+ # heap snapshot of a live app without restarting it
299
+ kill -USR1 <pid> # inspector comes up on :9229, then attach and snapshot
300
+ ```
301
+
302
+ - **Profile with `NODE_ENV=production` when chasing React costs.** The dev
303
+ reconciler's bookkeeping (`runWithFiberInDEV`) inflates commit times and
304
+ clutters the tree; for layout and paint costs it matters less.
305
+ - The inspector port is an arbitrary-code-execution door. Keep it on
306
+ `127.0.0.1` (the default); for remote boxes, tunnel it with
307
+ `ssh -L 9229:localhost:9229`.
308
+ - The React DevTools Profiler names the _components_ per commit; the V8
309
+ profiler names the _functions_.
310
+
311
+ ## `0x` {#0x}
312
+
313
+ **Out of the box.** 0x@6.0.0.
314
+
315
+ `0x -- node app.js` runs the process under the V8 sampling profiler and, on
316
+ exit, writes a self-contained interactive `flamegraph.html`, with
317
+ `performUnitOfWork`/`completeWork`, `yoga` and your components searchable.
318
+
319
+ ```sh
320
+ # profile an example, write the output into a throwaway dir
321
+ npx 0x --output-dir /tmp/rx11-prof -- node --import tsx examples/dashboard.jsx
322
+ # interact with the window until the slow thing happens, then Ctrl-C
323
+ # -> file:///tmp/rx11-prof/flamegraph.html
324
+
325
+ # tips inside the flamegraph UI:
326
+ # search "yoga" -> layout cost
327
+ # search "performUnitOfWork" -> react reconcile cost
328
+ # "Merge" view -> collapses recursion for a cleaner ranking
329
+ ```
330
+
331
+ clinic.js's flame command wraps 0x; use 0x directly.
332
+
333
+ - Sampling profiler: sub-millisecond handlers need a longer capture, so
334
+ drive the interaction in a loop.
335
+ - Profile with `NODE_ENV=production` when measuring React itself.
336
+ - Under the tsx loader, frames from `.jsx` files show transformed function
337
+ names; component names usually survive.
338
+ - 0x is CPU-only and says nothing about _which commit_ or _why it
339
+ re-rendered_; pair it with the DevTools Profiler and why-did-you-render,
340
+ and use `node --inspect` for heap questions.
341
+
342
+ ## why-did-you-render {#why-did-you-render}
343
+
344
+ **Out of the box.** @welldone-software/why-did-you-render@10.0.1.
345
+
346
+ Patches the `React` namespace so flagged components report _avoidable_
347
+ re-renders — props or hook state changed by reference but equal by value.
348
+ It hooks React, not `react-dom`, so it works under any renderer.
349
+
350
+ ```jsx
351
+ import React from 'react';
352
+ import whyDidYouRender from '@welldone-software/why-did-you-render';
353
+ import { createRoot } from 'react-x11';
354
+
355
+ whyDidYouRender(React); // before anything renders
356
+
357
+ const Label = ({ user }) => <text>{`hi ${user.name}`}</text>;
358
+ Label.whyDidYouRender = true;
359
+
360
+ // Every render passes a fresh-but-equal object — the classic wasted render:
361
+ const App = () => (
362
+ <window width={300} height={200} title="wdyr">
363
+ <box style={{ flexGrow: 1 }}>
364
+ <Label user={{ name: 'ada' }} />
365
+ </box>
366
+ </window>
367
+ );
368
+
369
+ (await createRoot()).render(<App />);
370
+ // terminal: "Label … props.user: different objects that are equal by value."
371
+ ```
372
+
373
+ - A re-render caused by a new-but-deep-equal object prop is reported with
374
+ `diffType: 'deepEquals'` and the offending path; hook tracking
375
+ (`trackHooks`, on by default) reports `setState` with an equal-by-value
376
+ object the same way.
377
+ - The default notifier prints `console.group` output in the terminal. A
378
+ custom `notifier` receives the structured
379
+ `{displayName, reason: {propsDifferences, hookDifferences}}` object — how
380
+ you assert on wasted renders in tests.
381
+ - Works with the automatic JSX runtime as compiled by tsx and esbuild; the
382
+ babel alias its docs describe for webpack is not needed.
383
+ - **Install it before components are defined and before the first render**
384
+ — it wraps `React.createElement` and the hook entry points on the
385
+ namespace.
386
+ - Dev-only: React's production build drops the internals it reads. Plain
387
+ `node`/`tsx` gives the dev build.
388
+ - Updates scheduled outside an event handler flush on a delayed task in
389
+ React 19, so in a scripted repro wait a macrotask before asserting the
390
+ notification fired.
391
+
392
+ DevTools tells you _that_ a component rendered; why-did-you-render tells you
393
+ the render was _wasted_ and which value to memoize.
@@ -0,0 +1,277 @@
1
+ # Forms and validation
2
+
3
+ There is no `<form>` element, so nothing submits itself — wire
4
+ `handleSubmit` to a `Button`'s `onPress` or to `<textinput onSubmit>`.
5
+
6
+ Everything else follows from **one signature**. Every value control — the
7
+ host elements `<textinput>` and `<textarea>`, and the widgets `Checkbox`,
8
+ `Switch`, `RadioGroup`, `Select` and `Slider` — calls `onChange(ev)` with a
9
+ change event carrying `ev.value`, `ev.target.value`, `ev.target.name` and,
10
+ for a checkbox, `ev.target.checked`. That is the shape DOM form libraries
11
+ read, so a library's handler goes straight in, with no per-widget bridge:
12
+
13
+ ```jsx
14
+ <textinput name="host" value={f.values.host} onChange={f.handleChange} />
15
+ <Checkbox name="agree" checked={f.values.agree} onChange={f.handleChange} />
16
+ <Select name="size" value={f.values.size} options={sizes} onChange={f.handleChange} />
17
+ ```
18
+
19
+ | library | how it goes in |
20
+ | ---------------------- | --------------------------------------------------------- |
21
+ | `react-hook-form` | `{...register('host')}`, or `<Controller>` for a widget |
22
+ | `formik` | `onChange={handleChange}`, `getFieldProps`, or `<Field>` |
23
+ | `downshift` | `getInputProps()` spread onto a `<textinput>` |
24
+ | `@tanstack/react-form` | `onChange={(ev) => field.handleChange(ev.value)}` — below |
25
+
26
+ Each row was run headless against the renderer: react-hook-form@7.84.0,
27
+ formik@2.4.9, downshift@9.4.0, @tanstack/react-form@1.33.2.
28
+
29
+ **The one thing to know**: a library whose field contract is
30
+ value-in/value-out — TanStack Form here — needs the value taken off the
31
+ event; a bare `onChange={field.handleChange}` stores the _event object_ as
32
+ the field value with no error.
33
+
34
+ A control that takes a `name` is a form field and reports an event. `Tabs`,
35
+ `Table` and the menus are not, and keep their plain callbacks
36
+ ([components](../components.md#the-change-event-and-name)).
37
+
38
+ ## TanStack Form {#tanstack-form}
39
+
40
+ **Out of the box.** @tanstack/react-form@1.33.2 (form-core 1.33.2).
41
+
42
+ A headless form state manager: field values, dirty and touched tracking,
43
+ sync and async validation (Standard Schema, so zod/valibot/arktype plug in
44
+ directly), and submission. The field contract is `field.state.value` +
45
+ `field.handleChange(value)` — a **value, not an event** — so each field
46
+ takes the value off the control's event, the same expression a DOM app
47
+ writes, for a `<textinput>` and a `Checkbox` alike:
48
+
49
+ ```jsx
50
+ onChange={(ev) => field.handleChange(ev.value)}
51
+ ```
52
+
53
+ The bare `onChange={field.handleChange}` stores the event object as the
54
+ field's value — no error, no warning, and validation sees an object where it
55
+ expected a string.
56
+
57
+ ```jsx
58
+ import React from 'react';
59
+ import { useForm } from '@tanstack/react-form';
60
+ import { Button } from 'react-x11';
61
+ import { z } from 'zod';
62
+
63
+ function ConnectForm({ connect }) {
64
+ const form = useForm({
65
+ defaultValues: { host: '', display: ':0' },
66
+ onSubmit: async ({ value }) => connect(value),
67
+ });
68
+ return (
69
+ <box style={{ flexDirection: 'column', gap: 8 }}>
70
+ <form.Field
71
+ name="host"
72
+ validators={{ onChange: z.string().min(3, 'too short') }}
73
+ >
74
+ {(field) => (
75
+ <box style={{ flexDirection: 'column' }}>
76
+ <textinput
77
+ value={field.state.value}
78
+ onChange={(ev) => field.handleChange(ev.value)}
79
+ />
80
+ {field.state.meta.errors.length > 0 && (
81
+ <text style={{ color: '#cc4444' }}>
82
+ {field.state.meta.errors[0].message}
83
+ </text>
84
+ )}
85
+ </box>
86
+ )}
87
+ </form.Field>
88
+ <Button label="Connect" onPress={() => form.handleSubmit()} />
89
+ </box>
90
+ );
91
+ }
92
+ ```
93
+
94
+ - There is no submit-on-Enter for free. Wire `form.handleSubmit()` to a
95
+ button's `onPress` and/or a window-level Enter key handler.
96
+ - Validation errors from a zod schema are error _objects_
97
+ (`errors[0].message`); a plain string validator returns strings. A
98
+ `<text>` child must be a string, so render `.message`.
99
+ - The SSR and Next helpers live under subpaths; the root import pulls in no
100
+ react-dom.
101
+
102
+ ## zod {#zod}
103
+
104
+ **Out of the box.** zod@4.4.3.
105
+
106
+ Schema validation, pure computation. Where it plugs in:
107
+
108
+ - **TanStack Form** — pass a schema straight into a field's
109
+ `validators: { onChange: z.string().min(3, 'too short') }`. Zod 4
110
+ implements Standard Schema, so no resolver package is needed.
111
+ - **React Hook Form** — via `@hookform/resolvers/zod`, with either
112
+ `register()` or `Controller`; see below.
113
+ - **Standalone** — validating a settings file or a config blob before
114
+ rendering is just `schema.safeParse(data)`.
115
+
116
+ ```jsx
117
+ import { z } from 'zod';
118
+
119
+ const settings = z.object({
120
+ display: z.string().regex(/^:\d+$/, 'DISPLAY looks like :0'),
121
+ scale: z.coerce.number().min(0.5).max(3).default(1),
122
+ });
123
+
124
+ function Settings({ raw }) {
125
+ const parsed = settings.safeParse(raw);
126
+ if (!parsed.success) {
127
+ // render the issues instead of crashing the app
128
+ return (
129
+ <box style={{ flexDirection: 'column' }}>
130
+ {parsed.error.issues.map((i) => (
131
+ <text
132
+ key={i.path.join('.')}
133
+ >{`${i.path.join('.')}: ${i.message}`}</text>
134
+ ))}
135
+ </box>
136
+ );
137
+ }
138
+ return <text>display {parsed.data.display}</text>;
139
+ }
140
+ ```
141
+
142
+ - Zod 4 field errors surface as issue objects; render `issue.message`, not
143
+ the issue itself.
144
+ - `valibot` and `arktype` are equally viable (also Standard Schema).
145
+ - Bundle-size arguments from the web do not apply — an X11 app ships no
146
+ bundle.
147
+
148
+ ## React Hook Form {#react-hook-form}
149
+
150
+ **Out of the box, both APIs.** react-hook-form@7.84.0 with
151
+ @hookform/resolvers@5.5.7.
152
+
153
+ `register()` returns DOM-shaped props (`{name, ref, onChange(e),
154
+ onBlur(e)}`) and reads `e.target.value` off the event, which is what
155
+ `<textinput>` provides, so the spread works:
156
+
157
+ ```jsx
158
+ const { register, handleSubmit } = useForm({ defaultValues: { host: '' } });
159
+ <textinput {...register('host')} />;
160
+ ```
161
+
162
+ The `ref` RHF attaches lands on a node whose `value` is writable (RHF
163
+ assigns `ref.value = ''` while registering). Through `register()`, typing
164
+ updates `getValues()`, `handleSubmit` receives the values,
165
+ `setValue(name, v)` writes through the ref so the field redraws, and
166
+ `setFocus(name)` calls the node's `.focus()`.
167
+
168
+ **`reset()` updates form state but does not clear an uncontrolled field's
169
+ text.** RHF writes values back through refs on reset only when its `isWeb`
170
+ probe (`typeof window !== 'undefined' && window.HTMLElement && document`)
171
+ passes, which it never does here; `setValue()` is not gated. To have
172
+ `reset()` clear the display, make the field controlled with `<Controller>`,
173
+ or assign `ref.current.value = ''` yourself.
174
+
175
+ `<Controller>` / `useController` is the choice for a controlled field.
176
+ `field.onChange` accepts a plain value or an event, so both are fine:
177
+
178
+ ```jsx
179
+ onChange={(ev) => field.onChange(ev.target.value)} // explicit
180
+ onChange={field.onChange} // RHF unwraps .target
181
+ ```
182
+
183
+ ```jsx
184
+ import React from 'react';
185
+ import { useForm, Controller } from 'react-hook-form';
186
+ import { zodResolver } from '@hookform/resolvers/zod';
187
+ import { Button } from 'react-x11';
188
+ import { z } from 'zod';
189
+
190
+ const schema = z.object({
191
+ host: z.string().min(3, 'too short'),
192
+ display: z.string(),
193
+ });
194
+
195
+ function ConnectForm({ connect }) {
196
+ const { control, handleSubmit } = useForm({
197
+ defaultValues: { host: '', display: ':0' },
198
+ resolver: zodResolver(schema),
199
+ mode: 'onChange',
200
+ });
201
+ return (
202
+ <box style={{ flexDirection: 'column', gap: 8 }}>
203
+ <Controller
204
+ name="host"
205
+ control={control}
206
+ render={({ field, fieldState }) => (
207
+ <box style={{ flexDirection: 'column' }}>
208
+ <textinput
209
+ value={field.value}
210
+ onChange={field.onChange}
211
+ onBlur={field.onBlur}
212
+ />
213
+ {fieldState.error && <text>{fieldState.error.message}</text>}
214
+ </box>
215
+ )}
216
+ />
217
+ <Button label="Connect" onPress={handleSubmit(connect)} />
218
+ </box>
219
+ );
220
+ }
221
+ ```
222
+
223
+ - A `Controller` field re-renders its subtree on every keystroke;
224
+ `register()` does not — the same performance argument RHF makes on the
225
+ web.
226
+ - `handleSubmit` returns a function. Call it: `handleSubmit(fn)` in an
227
+ `onPress` is the curried form, and there is no submit event to pass.
228
+
229
+ ## Formik {#formik}
230
+
231
+ **Out of the box.** formik@2.4.9.
232
+
233
+ All three of Formik's field styles work:
234
+
235
+ ```jsx
236
+ import { Formik, Field, useFormik } from 'formik';
237
+
238
+ // 1. the spread
239
+ <textinput {...formik.getFieldProps('host')} />
240
+
241
+ // 2. by hand
242
+ <textinput
243
+ name="host"
244
+ value={formik.values.host}
245
+ onChange={formik.handleChange}
246
+ onBlur={formik.handleBlur}
247
+ />
248
+
249
+ // 3. <Field> with a render prop
250
+ <Field name="host">
251
+ {({ field }) => <textinput {...field} />}
252
+ </Field>
253
+ ```
254
+
255
+ `handleChange` reads `target.name` to decide which field to write, so the
256
+ `name` prop is required in all of these. The widgets take the same handler:
257
+
258
+ ```jsx
259
+ <Checkbox
260
+ name="agree"
261
+ checked={formik.values.agree}
262
+ onChange={formik.handleChange}
263
+ >
264
+ I agree
265
+ </Checkbox>
266
+ ```
267
+
268
+ `ev.target` is `{ type: 'checkbox', name, value, checked }`, which is what
269
+ `handleChange` destructures — `type` is how it knows to take `checked`.
270
+
271
+ - `<Formik>`'s render-prop form and `useFormik` both work; there is no
272
+ `<form>` element, so call `submitForm()` (or `handleSubmit()`) yourself
273
+ from a `Button` or from `<textinput onSubmit>`.
274
+ - Passing `handleChange` a non-event object — a hand-rolled fake event with
275
+ no `target` — throws
276
+ `TypeError: Cannot read properties of undefined (reading 'type')` rather
277
+ than no-opping. Pass the real event.