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,474 @@
1
+ # Testing
2
+
3
+ Testing Library does not work here and no shim will fix it: its queries are
4
+ built on a DOM document. Its five failure modes are in the
5
+ [negative-results register](../ecosystem.md#render-time); a jsdom shim only
6
+ makes the query fail differently.
7
+
8
+ The replacement ships in the box: **[`react-x11/test`](../testing.md)** —
9
+ `renderX11`, `screen`, `userEvent`, `waitFor` and pixel assertions, over a
10
+ real X server running in your test process. Read that page first. Below is
11
+ the hand-rolled version, for a test that wants the connection itself, and
12
+ the third-party runners and tools that work alongside it.
13
+
14
+ ## The harness, by hand {#harness}
15
+
16
+ node-x11 ships a pure-JS X server that runs in your test process, so a test
17
+ needs no `$DISPLAY`, no Xvfb, and no native dependency. This is what
18
+ `renderX11` does, spelled out for a test that holds the pieces directly.
19
+
20
+ ```jsx
21
+ // helpers/harness.jsx
22
+ import React from 'react';
23
+ import { createRoot } from 'react-x11';
24
+ import xserver from 'x11/lib/xserver/index.js';
25
+
26
+ export async function createHeadlessRoot() {
27
+ const server = xserver.createServer({ width: 640, height: 480 });
28
+ const [serverEnd, clientEnd] = xserver.createStreamPair();
29
+ server.addClientStream(serverEnd);
30
+ // `stream` reaches a server that is not on the other end of $DISPLAY,
31
+ // which is the whole trick; the root owns the connection and closes it
32
+ // on unmount.
33
+ return await createRoot({ stream: clientEnd });
34
+ }
35
+
36
+ /** The render callback hands back the ntk window instance. `root.render`
37
+ * is synchronous, so this stays a plain helper — only obtaining the root
38
+ * is async, and that happens once. */
39
+ export const render = (root, element) =>
40
+ new Promise((resolve) => root.render(element, (wnd) => resolve(wnd)));
41
+
42
+ export const tick = () => new Promise((r) => setImmediate(r));
43
+ ```
44
+
45
+ ```jsx
46
+ import test from 'node:test';
47
+ import assert from 'node:assert/strict';
48
+ import { createHeadlessRoot, render, tick } from './helpers/harness.jsx';
49
+
50
+ test('paints a box', async () => {
51
+ const root = await createHeadlessRoot();
52
+ const wnd = await render(
53
+ root,
54
+ <window width={320} height={240} title="probe">
55
+ <box style={{ flexGrow: 1, backgroundColor: '#204080' }} />
56
+ </window>,
57
+ );
58
+ await tick(); // layout runs on the frame clock, after the commit
59
+
60
+ const node = wnd._reactX11Node; // the root drawn node
61
+ assert.equal(node.children[0].kind, 'box');
62
+ assert.deepEqual(node.children[0].abs, {
63
+ x: 0,
64
+ y: 0,
65
+ width: 320,
66
+ height: 240,
67
+ });
68
+
69
+ await root.unmount();
70
+ });
71
+ ```
72
+
73
+ **The `tick()` is not optional.** `render`'s callback resolves at commit;
74
+ layout runs a task later on the frame clock, so every `abs` rect is
75
+ `{x: 0, y: 0, width: 0, height: 0}` until you yield once. Assert on `kind`
76
+ and `props` immediately, never on geometry.
77
+
78
+ `wnd._reactX11Node` is the root of the drawn tree. Each node carries `kind`
79
+ (the element name), `props`, `children`, and `abs` (its laid-out rect):
80
+ walk it. It is not a DOM; `ownerDocument` and `getClientRects()` exist only
81
+ because React DevTools' `measureHostInstance` dereferences them.
82
+
83
+ A `<text>` node holds `textchunk` children whose `.text` is the string:
84
+
85
+ ```js
86
+ export function textOf(node) {
87
+ const out = [];
88
+ (function walk(n) {
89
+ if (n.kind === 'textchunk') out.push(n.text);
90
+ (n.children ?? []).forEach(walk);
91
+ })(node);
92
+ return out.join('');
93
+ }
94
+ ```
95
+
96
+ To run with no X server at all — faster, and enough for most component
97
+ tests — pass `createRoot({ app })` a mock ntk app that records canvas
98
+ operations instead of painting them. `react-x11/test` exports the one the
99
+ renderer's unit tests use, `createMockApp()`, and
100
+ `renderX11(el, { backend: 'mock' })` builds it for you
101
+ ([mock backend](../testing.md#the-mock-backend)).
102
+
103
+ ## Driving events {#driving-events}
104
+
105
+ `@testing-library/user-event` throws on `setup()`
106
+ ([register](../ecosystem.md#render-time)); use `react-x11/test`'s
107
+ `userEvent`, or emit the raw ntk event on the window. react-x11 subscribes
108
+ to the ntk window's emitter, so this exercises the whole synthetic-event
109
+ path: hit testing, capture, bubble, default actions.
110
+
111
+ ```jsx
112
+ test('a synthetic click reaches the handler', async () => {
113
+ const root = await createHeadlessRoot();
114
+ let clicks = 0;
115
+ const wnd = await render(
116
+ root,
117
+ <window width={200} height={100}>
118
+ <box style={{ flexGrow: 1 }} onClick={() => (clicks += 1)} />
119
+ </window>,
120
+ );
121
+ await tick();
122
+
123
+ wnd.emit('mousedown', { x: 20, y: 20, button: 1, buttons: 1 });
124
+ wnd.emit('mouseup', { x: 20, y: 20, button: 1, buttons: 0 });
125
+ await tick();
126
+
127
+ assert.equal(clicks, 1);
128
+ await root.unmount();
129
+ });
130
+ ```
131
+
132
+ Keyboard input is the same shape — `wnd.emit('keydown', { keycode,
133
+ codepoint, buttons })` — with the keysym registered in
134
+ `app.X.keycode2keysyms` so the renderer can resolve it. `button` is the X
135
+ button number: left is `1`, not `0`.
136
+
137
+ ## Runners {#runners}
138
+
139
+ **`node:test` (built-in) is the baseline.** react-x11's own suite uses it,
140
+ and it handles the ESM graph without configuration. Run `.jsx` files through
141
+ `tsx`: `node --import tsx --test 'test/**/*.test.jsx'`.
142
+
143
+ ### Vitest {#vitest}
144
+
145
+ **Out of the box.** vitest@4.1.10.
146
+
147
+ Native ESM, built-in JSX and TypeScript transform, watch mode, snapshots. A
148
+ `.test.jsx` that mounts a tree, drives a prop update and asserts on the node
149
+ tree and the paint-op log runs green with no flags. The one setting that
150
+ matters:
151
+
152
+ ```js
153
+ // vitest.config.mjs
154
+ import { defineConfig } from 'vitest/config';
155
+
156
+ export default defineConfig({
157
+ test: { environment: 'node' }, // the default; never 'jsdom'
158
+ esbuild: { jsx: 'automatic' },
159
+ });
160
+ ```
161
+
162
+ **Never `environment: 'jsdom'`.** It _defines_ `window` and `document`,
163
+ which silently flips environment detection in third-party libraries —
164
+ `@tanstack/query-core` decides it is in a browser and changes retry and
165
+ refetch behaviour, for one. `'node'` is the default; do not copy a web
166
+ project's config across.
167
+
168
+ - Vitest runs test files in worker threads by default. The in-process X
169
+ server works there; `pool: 'forks'` is the escape hatch if long-lived
170
+ server streams misbehave.
171
+ - `vi.useFakeTimers()` shares the [sinon caveat](#sinon): pass an explicit
172
+ `toFake` list, or React's scheduling and the renderer's paint scheduling
173
+ freeze.
174
+
175
+ ### Jest {#jest}
176
+
177
+ **Works, but only in real ESM mode.** jest@30.4.2.
178
+
179
+ Jest's default CJS path cannot work: `babel-jest` down-compiles to
180
+ CommonJS, and `react-x11/src/Reconciler.js` and `src/DevToolsIntegration.js`
181
+ call `createRequire(import.meta.url)`, which has no CJS equivalent, so Jest's
182
+ own suggestions lead to
183
+ `SyntaxError: Cannot use 'import.meta' outside a module`. Both dead ends are
184
+ in the [register](../ecosystem.md#install-and-import-time). What works:
185
+
186
+ ```js
187
+ // jest.config.mjs — run with NODE_OPTIONS=--experimental-vm-modules
188
+ export default {
189
+ extensionsToTreatAsEsm: ['.jsx'],
190
+ transform: {
191
+ '\\.jsx$': [
192
+ 'babel-jest',
193
+ {
194
+ presets: [
195
+ [
196
+ '@babel/preset-env',
197
+ { modules: false, targets: { node: 'current' } },
198
+ ],
199
+ ['@babel/preset-react', { runtime: 'automatic' }],
200
+ ],
201
+ },
202
+ ],
203
+ },
204
+ };
205
+ ```
206
+
207
+ `modules: false` is the load-bearing part: with no CJS transform, Jest runs
208
+ the same ESM graph as everything else. `node:test` and Vitest need none of
209
+ this.
210
+
211
+ ## msw {#msw}
212
+
213
+ **Out of the box — `msw/node`.** msw@2.15.0.
214
+
215
+ Request-level API mocking. In Node, `setupServer` patches `http`, `https`,
216
+ `fetch`/undici and `XMLHttpRequest`, so `http.get(url, resolver)` handlers
217
+ intercept real requests — no service worker, no DOM. A react-x11 app's data
218
+ layer is ordinary Node networking: components call `fetch`, msw answers, the
219
+ renderer repaints.
220
+
221
+ ```jsx
222
+ import { setupServer } from 'msw/node';
223
+ import { http, HttpResponse } from 'msw';
224
+
225
+ const server = setupServer(
226
+ http.get('https://api.example.test/user', () =>
227
+ HttpResponse.json({ name: 'Ada' }),
228
+ ),
229
+ );
230
+ before(() => server.listen({ onUnhandledRequest: 'error' }));
231
+ after(() => server.close());
232
+
233
+ function User() {
234
+ const [name, setName] = React.useState('loading');
235
+ React.useEffect(() => {
236
+ let alive = true;
237
+ fetch('https://api.example.test/user')
238
+ .then((r) => r.json())
239
+ .then((d) => alive && setName(d.name));
240
+ return () => {
241
+ alive = false;
242
+ };
243
+ }, []);
244
+ return <text>{name}</text>;
245
+ }
246
+ ```
247
+
248
+ - The fetch → `setState` → commit round trip is asynchronous, and one
249
+ `setImmediate` tick is **not** enough. Poll the tree with a deadline.
250
+ - `server.listen()` patches process-wide networking. Call `server.close()`
251
+ in `after`, or the harness and msw can leave process state between files.
252
+ - The in-process X server connection is a plain stream, not HTTP; msw never
253
+ touches it.
254
+ - `onUnhandledRequest: 'error'` makes any unmocked request a loud failure —
255
+ the right default for hermetic UI tests.
256
+ - nock also works; prefer it only for its scope-level assertions
257
+ (`scope.done()`) on legacy `http.request`-based clients.
258
+
259
+ ## fast-check {#fast-check}
260
+
261
+ **Out of the box.** fast-check@4.9.0.
262
+
263
+ Property-based testing: generators plus a property, run against hundreds
264
+ of random inputs, with any failure _shrunk_ to a minimal counterexample and
265
+ a replay seed. The renderer has two oracles that cannot lie:
266
+
267
+ - **Convergence** — `mount(A)` then `update(A → B)` must produce the same
268
+ tree as a fresh `mount(B)`.
269
+ - **Damage soundness** — a partial repaint must equal a forced full repaint.
270
+
271
+ ```jsx
272
+ import fc from 'fast-check';
273
+
274
+ const { node } = fc.letrec((tie) => ({
275
+ node: fc.oneof(
276
+ { maxDepth: 3 },
277
+ fc.record({
278
+ kind: fc.constant('text'),
279
+ label: fc.constantFrom('a', 'hello x11'),
280
+ }),
281
+ fc.record({
282
+ kind: fc.constant('box'),
283
+ style: fc.record(
284
+ {
285
+ width: fc.integer({ min: 10, max: 120 }),
286
+ flexDirection: fc.constantFrom('row', 'column'),
287
+ },
288
+ { requiredKeys: [] },
289
+ ),
290
+ children: fc.array(tie('node'), { maxLength: 3 }),
291
+ }),
292
+ ),
293
+ }));
294
+
295
+ const toEl = (s, key) =>
296
+ s.kind === 'text' ? (
297
+ <text key={key}>{s.label}</text>
298
+ ) : (
299
+ <box key={key} style={s.style}>
300
+ {s.children.map(toEl)}
301
+ </box>
302
+ );
303
+ const win = (kids) => (
304
+ <window width={320} height={240}>
305
+ {kids.map(toEl)}
306
+ </window>
307
+ );
308
+ const snap = (n) => ({
309
+ kind: n.kind,
310
+ abs: { ...n.abs },
311
+ children: n.children.map(snap),
312
+ });
313
+
314
+ await fc.assert(
315
+ fc.asyncProperty(
316
+ fc.array(node, { maxLength: 4 }),
317
+ fc.array(node, { maxLength: 4 }),
318
+ async (a, b) => {
319
+ const root1 = await createHeadlessRoot();
320
+ const w1 = await render(root1, win(a));
321
+ await tick();
322
+ await render(root1, win(b)); // update path
323
+ await tick();
324
+ const root2 = await createHeadlessRoot();
325
+ const w2 = await render(root2, win(b)); // fresh-mount path
326
+ await tick();
327
+ assert.deepStrictEqual(snap(w1._reactX11Node), snap(w2._reactX11Node));
328
+ },
329
+ ),
330
+ { numRuns: 30 },
331
+ );
332
+ ```
333
+
334
+ - Compare structure (`kind` plus `abs` rects), not paint-op logs: op logs
335
+ accumulate across paints on the update path, so they only match after a
336
+ forced full repaint on both sides.
337
+ - Keep trees shallow; each run is a real yoga layout pass.
338
+ - Include a mid-sequence unmount/remount and `key` permutations in the
339
+ generators; index keys exercise much less of `insertBefore`/`removeChild`
340
+ than keyed reorders.
341
+ - A seeded PRNG would also do with no dependency; fast-check buys shrinking
342
+ and `{ seed, path }` replay.
343
+
344
+ ## sinon {#sinon}
345
+
346
+ **Out of the box, with one required restriction.** sinon@22.1.0.
347
+
348
+ Spies, stubs and `@sinonjs/fake-timers`. Fake timers work — a toast that
349
+ auto-dismisses via `setTimeout` dismisses on `clock.tick(1000)` — but the
350
+ default configuration is a trap.
351
+
352
+ **Never fake `setImmediate` or `queueMicrotask`.** Sinon's default `toFake`
353
+ list includes them; React's scheduler drives work with them in Node, and the
354
+ renderer schedules paints via `setImmediate` when there is no frame clock.
355
+ Faked, every render, effect flush and repaint freezes until a `clock.tick` —
356
+ usually a test that hangs at the first `await`. Pass an explicit list:
357
+
358
+ ```js
359
+ const clock = sinon.useFakeTimers({
360
+ toFake: ['setTimeout', 'clearTimeout', 'Date'],
361
+ });
362
+ ```
363
+
364
+ The renderer's animation clock reads `Date.now()` at call time, so a fake
365
+ `Date` reaches style transitions.
366
+
367
+ ```jsx
368
+ test('toast auto-dismisses', async () => {
369
+ const clock = sinon.useFakeTimers({
370
+ toFake: ['setTimeout', 'clearTimeout', 'Date'],
371
+ });
372
+ try {
373
+ const root = await createHeadlessRoot();
374
+ const wnd = await render(
375
+ root,
376
+ <window width={100} height={50}>
377
+ <Toast />
378
+ </window>,
379
+ );
380
+ await tick(); // mount + passive effect
381
+ clock.tick(1000); // fires the timeout synchronously
382
+ await tick();
383
+ await tick(); // setState -> commit -> repaint: two tasks
384
+ assert.strictEqual(wnd._reactX11Node.children.length, 0);
385
+ } finally {
386
+ clock.restore(); // always; a leaked fake Date poisons the file
387
+ }
388
+ });
389
+ ```
390
+
391
+ - Two `setImmediate` ticks after `clock.tick`, not one — the timer
392
+ callback's `setState` commits a task later.
393
+ - `clock.restore()` in `finally`, every time. A leaked fake `Date` breaks
394
+ later tests' transitions and timestamps in the same process.
395
+ - The text-input caret blink uses `setInterval`; faking it without ticking
396
+ leaves the caret timer pending — harmless, but it shows in
397
+ `clock.countTimers()`.
398
+ - **Versus `node:test` mock timers**: `t.mock.timers.enable({ apis:
399
+ ['setTimeout'] })` plus `t.mock.timers.tick(1000)` handles the plain-timer
400
+ case with no dependency and auto-restores. Prefer it; use sinon for fake
401
+ `Date` (the animation clock), `clock.countTimers()`, or spies and stubs.
402
+
403
+ ## pixelmatch {#pixelmatch}
404
+
405
+ **Out of the box.** pixelmatch@7.2.0.
406
+
407
+ Perceptual image diff: two RGBA buffers plus an output buffer in,
408
+ changed-pixel count and a painted diff image out. Anti-aliasing aware, zero
409
+ dependencies. Render through the harness and read pixels back with
410
+ `getImageData` — no display needed. `jest-image-snapshot` cannot do this (it
411
+ wants a Buffer and receives a window object;
412
+ [register](../ecosystem.md#render-time)), but the pipeline under it is four
413
+ lines.
414
+
415
+ `ctx.getImageData(x, y, w, h)` resolves to a canvas `ImageData`; hand
416
+ pixelmatch its `.data`, already straight RGBA in a `Uint8ClampedArray` with
417
+ opaque pixels at alpha 255. Readback races the paint: poll a known pixel
418
+ before taking the comparison frame.
419
+
420
+ ```jsx
421
+ import fs from 'node:fs';
422
+ import pixelmatch from 'pixelmatch';
423
+ import { PNG } from 'pngjs';
424
+
425
+ const ctx = wnd.getContext('2d');
426
+ const diff = new PNG({ width: 320, height: 240 });
427
+
428
+ const before = (await ctx.getImageData(0, 0, 320, 240)).data;
429
+ // ...re-render with the changed prop, wait for the repaint...
430
+ const after = (await ctx.getImageData(0, 0, 320, 240)).data;
431
+ const changed = pixelmatch(before, after, diff.data, 320, 240, {
432
+ threshold: 0.1,
433
+ });
434
+ if (changed > 0) fs.writeFileSync('diff.png', PNG.sync.write(diff));
435
+ ```
436
+
437
+ - **Fonts are the determinism problem.** Glyph indices travel on the wire,
438
+ so pin every family with ntk's `StaticFontSource`; never let a snapshot script fall through to
439
+ `FontconfigFontSource`. Pinned, two renders of the same tree diff to zero
440
+ pixels; unpinned, the diff is noise.
441
+ - `threshold` is a per-pixel colour distance, not a pass/fail budget; gate
442
+ on the returned count.
443
+ - Buffers must have identical dimensions, or pixelmatch throws. After a
444
+ window resize, re-read rather than crop.
445
+ - fontkit version bumps change hinting and advance rounding. Treat pixel
446
+ diffs as tolerance-based regression tests; gate CI exactly on tree or
447
+ op-log assertions.
448
+ - Under Xvfb, expect nonzero noise from the server's own rendering; the
449
+ zero-noise floor is specific to the in-process JS server.
450
+
451
+ ## react-test-renderer {#react-test-renderer}
452
+
453
+ **Works, but do not build on it.** react-test-renderer@19.2.8.
454
+
455
+ React's headless renderer treats _any_ string element type as a host
456
+ element, so react-x11 vocabulary passes through and coarse JSON snapshots
457
+ work. But:
458
+
459
+ 1. **It is deprecated.** Importing it prints
460
+ `react-test-renderer is deprecated`; it still ships in 19.2.x, but React
461
+ discourages new usage.
462
+ 2. **It bypasses the actual renderer.** No prop validation, style
463
+ resolution, yoga layout or paint, so a tree that snapshots fine can still
464
+ throw `react-x11: <box width=…> is a style property` or lay out wrong
465
+ under react-x11.
466
+
467
+ The harness above is barely more code and exercises the real
468
+ `createInstance`/`commitUpdate` path with layout. Use react-test-renderer
469
+ only as a bridge when porting an existing suite.
470
+
471
+ - Set `globalThis.IS_REACT_ACT_ENVIRONMENT = true` in test setup, or every
472
+ `act` call warns.
473
+ - `toJSON()` records the raw props, so snapshots are of _input_, not of what
474
+ would render.