react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
package/src/Reconciler.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  getCurrentUpdatePriority,
15
15
  setCurrentUpdatePriority,
16
16
  resolveUpdatePriority,
17
+ setSyncFlush,
17
18
  } from './priority.js';
18
19
  import {
19
20
  WindowNode,
@@ -23,68 +24,104 @@ import {
23
24
  TextChunkNode,
24
25
  ImageNode,
25
26
  CanvasNode,
26
- ScrollViewNode,
27
27
  TextInputNode,
28
28
  TextAreaNode,
29
+ appearanceChanged,
30
+ beginWindowMaps,
31
+ flushWindowMaps,
29
32
  flushWindowRestacks,
33
+ windowAttributes,
30
34
  } from './nodes.js';
31
- import { GlAreaNode } from './glnodes.js';
32
- import { SCENE_KINDS, UNSUPPORTED_KINDS, createSceneNode } from './scene3d.js';
35
+ import { hasDropProps } from './dnd.js';
36
+ import { AppProvider } from './appcontext.js';
37
+ import { defaultRootHandlers, setErrorHandler } from './errors.js';
33
38
  import {
34
- MarkdownNode,
35
- HtmlNode,
36
- SvgNode,
37
- SvgChildNode,
38
- TexNode,
39
- } from './richnodes.js';
40
-
41
- const require = createRequire(import.meta.url);
42
- const packageJson = require('../package.json');
39
+ registerApp,
40
+ unregisterApp,
41
+ hooks as traceHooks,
42
+ } from './trace-registry.js';
43
+ import { hooks as a11yHooks, startA11y } from './a11y.js';
44
+ import { beginStartup } from './startup.js';
45
+ import { beginCompose } from './compose.js';
46
+ import { beginFocus } from './events.js';
47
+ import { beginKeyboard } from './keyboard.js';
48
+ import { beginCompositing, endCompositing } from './compositing.js';
49
+ import { beginScreens, endScreens } from './screens.js';
50
+ import { beginScale, scaleOf } from './scale.js';
51
+ import { beginDesktopSettings, endDesktopSettings } from './desktopsettings.js';
52
+ import { endIdle } from './idle.js';
53
+ import { endKeyboardState } from './keyboardstate.js';
54
+ import { endXSettings } from './xsettings.js';
55
+ import { watchAppearance } from './appearance.js';
56
+ import { ForeignNode } from './foreignnodes.js';
57
+ import { GlAreaNode } from './glnodes.js';
58
+ import { createRegisteredNode, registeredElements } from './registry.js';
59
+ import { SvgNode, SvgChildNode } from './svgnodes.js';
60
+ import { loadLayout } from './yoga.js';
61
+
62
+ // The renderer name and version DevTools shows. Read from package.json so
63
+ // they cannot drift — but **guarded**, because a single-file bundle has no
64
+ // package.json beside it to read: `import.meta.url` is then the bundle's,
65
+ // `'../package.json'` resolves to something else or to nothing, and an
66
+ // unguarded require takes the whole app down at import time with
67
+ // `Cannot find module '../package.json'`. Losing the version string in a
68
+ // bundle costs a line in the DevTools panel; throwing costs the app. See
69
+ // docs/packaging.md.
70
+ const PACKAGE_NAME = 'react-x11';
71
+ let PACKAGE_VERSION = '0.0.0-bundled';
72
+ try {
73
+ PACKAGE_VERSION = createRequire(import.meta.url)('../package.json').version;
74
+ } catch {
75
+ // bundled, or installed somewhere without the manifest beside us
76
+ }
43
77
 
44
- const HOST_TYPES = [
78
+ // The vocabulary, in the order the unknown-element error lists it. Exported
79
+ // so a test can sweep every element rather than a list of them that was
80
+ // true once (test/element-props.test.js).
81
+ export const HOST_TYPES = [
45
82
  'window',
46
83
  'popup',
47
84
  'box',
48
85
  'text',
49
86
  'image',
50
87
  'canvas',
51
- 'scrollview',
52
88
  'textinput',
53
89
  'textarea',
54
- 'markdown',
55
- 'html',
56
90
  'svg',
57
- 'tex',
58
91
  'glarea',
92
+ 'foreign',
59
93
  ];
60
94
 
61
- const isEventProp = (name) => /^on[A-Z]/.test(name);
62
-
63
- // Props forwarded to ntk createWindow. Event handlers are dispatched by the
64
- // EventManager from current props (never registered at creation, so they
65
- // cannot go stale) and children are handled by the tree.
66
- function windowAttributes(props) {
67
- const attributes = {};
68
- for (const key of Object.keys(props)) {
69
- if (key === 'children' || isEventProp(key)) continue;
70
- attributes[key] = props[key];
71
- }
72
- return attributes;
73
- }
74
-
75
95
  const HostConfig = {
76
96
  supportsMutation: true,
77
97
  supportsPersistence: false,
78
98
  supportsHydration: false,
79
99
  supportsResources: false,
80
100
  supportsSingletons: false,
101
+ // Queries live in `react-x11/test` and walk the retained node tree
102
+ // directly, which keeps the failure messages ours and keeps them off
103
+ // react-reconciler's internal host-config contract. React's own selectors
104
+ // would need eight more functions to answer the same questions.
81
105
  supportsTestSelectors: false,
82
106
  supportsMicrotasks: true,
83
107
  isPrimaryRenderer: true,
84
- warnsIfNotActing: false,
85
-
86
- rendererVersion: packageJson.version,
87
- rendererPackageName: packageJson.name,
108
+ // (`warnsIfNotActing` used to sit here. react-reconciler 0.33 reads it as
109
+ // a bare expression statement and discards it — the act warning is gated
110
+ // on `globalThis.IS_REACT_ACT_ENVIRONMENT`, which `react-x11/test`'s `act`
111
+ // sets. A flag that does nothing is worse than no flag.)
112
+
113
+ // What DevTools shows for this renderer. These are the whole story in
114
+ // react-reconciler 0.33: `injectIntoDevTools()` takes no arguments and
115
+ // reads them from here.
116
+ rendererVersion: PACKAGE_VERSION,
117
+ rendererPackageName: PACKAGE_NAME,
118
+ // Surfaces as `internals.rendererConfig`, which is where a renderer puts
119
+ // things only its own DevTools integration understands — React Native's
120
+ // `getInspectorDataForViewTag` is the archetype. Nothing in the standalone
121
+ // DevTools reads it for a renderer like this one, so there is nothing
122
+ // honest to put here. In particular `findFiberByHostInstance` does not
123
+ // belong here: 0.33 dropped it, and DevTools 7 only ever tested it for
124
+ // *presence*, to tell a fiber renderer from the pre-fiber kind.
88
125
  extraDevToolsConfig: null,
89
126
 
90
127
  scheduleTimeout: setTimeout,
@@ -96,7 +133,6 @@ const HostConfig = {
96
133
  return {
97
134
  isInsideText: false,
98
135
  isInsideSvg: false,
99
- isInsideRichText: false,
100
136
  isInside3d: false,
101
137
  };
102
138
  },
@@ -106,10 +142,6 @@ const HostConfig = {
106
142
  isInsideText: parentHostContext.isInsideText || type === 'text',
107
143
  // <svg> children are declarative SVG elements, not react-x11 nodes
108
144
  isInsideSvg: parentHostContext.isInsideSvg || type === 'svg',
109
- // <markdown>/<html>/<tex> take their content as a string child
110
- // (react-markdown style); no elements are allowed inside
111
- isInsideRichText:
112
- type === 'markdown' || type === 'html' || type === 'tex',
113
145
  // inside <glarea> the children are scene nodes, not drawn nodes
114
146
  isInside3d: parentHostContext.isInside3d || type === 'glarea',
115
147
  };
@@ -122,13 +154,24 @@ const HostConfig = {
122
154
  },
123
155
 
124
156
  prepareForCommit() {
157
+ // …so that a <window> realized during the mutation phase waits to be
158
+ // mapped until React has finished hiding whatever it hides (nodes.js,
159
+ // beginWindowMaps)
160
+ beginWindowMaps();
161
+ traceHooks.commitStart?.();
125
162
  return null;
126
163
  },
127
164
 
128
165
  // child <window>s that moved in the tree restack here, so a reorder costs
129
- // one pass instead of one per insertBefore
166
+ // one pass instead of one per insertBefore.
130
167
  resetAfterCommit() {
131
168
  flushWindowRestacks();
169
+ // after the restacking, so a subtree of windows arrives on screen in its
170
+ // final order rather than shuffling once it is up
171
+ flushWindowMaps();
172
+ traceHooks.commitEnd?.();
173
+ // the AT-SPI bridge flushes its queued tree/state diffs per commit
174
+ a11yHooks.commit?.();
132
175
  },
133
176
 
134
177
  createInstance(type, props, rootContainer, hostContext, internalHandle) {
@@ -140,12 +183,6 @@ const HostConfig = {
140
183
  node._reactFiber = internalHandle;
141
184
  return node;
142
185
  }
143
- if (hostContext.isInsideRichText) {
144
- throw new Error(
145
- `react-x11: <${type}> is not allowed inside <markdown>/<html>/<tex>; ` +
146
- 'their content is a string child (or the source prop).',
147
- );
148
- }
149
186
  if (hostContext.isInsideText && type !== 'text') {
150
187
  throw new Error(
151
188
  `react-x11: <${type}> is not allowed inside <text>; only nested ` +
@@ -153,21 +190,14 @@ const HostConfig = {
153
190
  );
154
191
  }
155
192
  if (hostContext.isInside3d) {
156
- const scene = createSceneNode(type, props, rootContainer);
157
- if (scene) {
158
- scene._reactFiber = internalHandle;
159
- return scene;
160
- }
161
- if (UNSUPPORTED_KINDS[type]) {
162
- throw new Error(
163
- `react-x11: <${type}> cannot work over indirect GLX — ` +
164
- `${UNSUPPORTED_KINDS[type]}. See docs/glx-plan.md.`,
165
- );
166
- }
193
+ // `<glarea>` is a leaf here: it owns the surface, the frame clock and
194
+ // the swap, and `onDraw` is the escape hatch. A *scene graph* over it
195
+ // meshes, materials, lights, post-processing, on either backend —
196
+ // is `@react-x11/components/three`, which brings its own reconciler.
167
197
  throw new Error(
168
- `react-x11: <${type}> is not a 3D element; inside <glarea> only ` +
169
- [...SCENE_KINDS].map((t) => `<${t}>`).join(', ') +
170
- ' are.',
198
+ `react-x11: <${type}> is not an element <glarea> takes no ` +
199
+ 'children. Draw through `onDraw`, or use ' +
200
+ '`@react-x11/components/three` for a scene graph. See docs/gl.md.',
171
201
  );
172
202
  }
173
203
  let node;
@@ -175,17 +205,22 @@ const HostConfig = {
175
205
  case 'window':
176
206
  // No X11 calls here: the render phase may be discarded. The real
177
207
  // window is created top-down in the commit phase (realize).
178
- node = new WindowNode(rootContainer, windowAttributes(props), props);
208
+ node = new WindowNode(
209
+ rootContainer,
210
+ windowAttributes(props, scaleOf(rootContainer)),
211
+ props,
212
+ );
179
213
  break;
180
214
  case 'popup':
181
- node = new PopupNode(rootContainer, windowAttributes(props), props);
215
+ node = new PopupNode(
216
+ rootContainer,
217
+ windowAttributes(props, scaleOf(rootContainer)),
218
+ props,
219
+ );
182
220
  break;
183
221
  case 'box':
184
222
  node = new BoxNode(props, rootContainer);
185
223
  break;
186
- case 'scrollview':
187
- node = new ScrollViewNode(props, rootContainer);
188
- break;
189
224
  case 'textinput':
190
225
  node = new TextInputNode(props, rootContainer);
191
226
  break;
@@ -203,48 +238,55 @@ const HostConfig = {
203
238
  case 'canvas':
204
239
  node = new CanvasNode(props, rootContainer);
205
240
  break;
206
- case 'markdown':
207
- node = new MarkdownNode(props, rootContainer);
208
- break;
209
- case 'html':
210
- node = new HtmlNode(props, rootContainer);
211
- break;
212
241
  case 'svg':
213
242
  node = new SvgNode(props, rootContainer);
214
243
  break;
215
- case 'tex':
216
- node = new TexNode(props, rootContainer);
217
- break;
218
244
  case 'glarea':
219
245
  node = new GlAreaNode(props, rootContainer);
220
246
  break;
221
- default:
222
- if (SCENE_KINDS.has(type) || UNSUPPORTED_KINDS[type]) {
223
- throw new Error(
224
- `react-x11: <${type}> is a 3D element and only works inside ` +
225
- '<glarea> (or the <Canvas3D> component).',
226
- );
247
+ case 'foreign':
248
+ // No X11 calls here either, and for a sharper reason than
249
+ // `<window>`'s: the window this element embeds belongs to another
250
+ // process, and a reparent issued from a render React then discards
251
+ // has moved it for real. WindowNode.realize does it in the commit
252
+ // phase (`_realizeChildWindows`).
253
+ node = new ForeignNode(props, rootContainer);
254
+ break;
255
+ default: {
256
+ // Third-party elements (issue #125). Built-ins stay a switch —
257
+ // they are code, not data — and the registry is consulted only
258
+ // once the switch has run out, so it costs nothing on the way in.
259
+ const registered = createRegisteredNode(
260
+ type,
261
+ props,
262
+ rootContainer,
263
+ hostContext,
264
+ );
265
+ if (registered) {
266
+ registered._reactFiber = internalHandle;
267
+ return registered;
227
268
  }
269
+ const custom = registeredElements();
228
270
  throw new Error(
229
271
  `react-x11: unknown element type <${type}>. Supported: ` +
230
272
  HOST_TYPES.map((t) => `<${t}>`).join(', ') +
231
- '.',
273
+ (custom.length > 0
274
+ ? `; registered: ${custom.map((t) => `<${t}>`).join(', ')}`
275
+ : '') +
276
+ '. Third-party elements are added with registerElement() from ' +
277
+ 'react-x11/host (docs/extending.md).',
232
278
  );
279
+ }
233
280
  }
234
281
  node._reactFiber = internalHandle;
235
282
  return node;
236
283
  },
237
284
 
238
285
  createTextInstance(text, rootContainer, hostContext) {
239
- if (
240
- !hostContext.isInsideText &&
241
- !hostContext.isInsideRichText &&
242
- !hostContext.isInsideSvg
243
- ) {
286
+ if (!hostContext.isInsideText && !hostContext.isInsideSvg) {
244
287
  throw new Error(
245
288
  `react-x11: raw text ${JSON.stringify(text)} must be wrapped in a ` +
246
- '<text> element (or be the string child of <markdown>/<html>/' +
247
- '<tex>/an SVG <text>).',
289
+ '<text> element (or be the string child of an SVG <text>).',
248
290
  );
249
291
  }
250
292
  return new TextChunkNode(text, rootContainer);
@@ -258,9 +300,13 @@ const HostConfig = {
258
300
  // Popups are not attached to the container or realized by a parent
259
301
  // window; commitMount realizes them against the screen root. autoFocus
260
302
  // and trapFocus need commitMount too — the node has to be in the tree
261
- // first, so it can find the EventManager that owns focus.
303
+ // first, so it can find the EventManager that owns focus. Drop targets
304
+ // likewise: registration needs the root, which insertion assigns.
262
305
  return (
263
- type === 'popup' || Boolean(props.autoFocus) || Boolean(props.trapFocus)
306
+ type === 'popup' ||
307
+ Boolean(props.autoFocus) ||
308
+ Boolean(props.trapFocus) ||
309
+ hasDropProps(props)
264
310
  );
265
311
  },
266
312
 
@@ -271,6 +317,9 @@ const HostConfig = {
271
317
  if (props.trapFocus) {
272
318
  instance._syncFocusScope?.();
273
319
  }
320
+ if (hasDropProps(props)) {
321
+ instance.root?._registerDropTarget?.(instance);
322
+ }
274
323
  if (props.autoFocus && typeof instance.focus === 'function') {
275
324
  instance.focus();
276
325
  }
@@ -286,6 +335,13 @@ const HostConfig = {
286
335
  // screen root.
287
336
  child.realize(null);
288
337
  }
338
+ // React's getPublicRootInstance answers from the root fiber's first
339
+ // child, and only when that child is a host component. `render()` wraps
340
+ // the tree in a context provider, which is not one, so it would answer
341
+ // null — the container keeps the list instead. Same answer as before:
342
+ // the first top-level node the tree put here.
343
+ (container._rootChildren ??= []).push(child);
344
+ a11yHooks.rootMounted?.(child);
289
345
  },
290
346
 
291
347
  insertBefore(parentInstance, child, beforeChild) {
@@ -301,6 +357,11 @@ const HostConfig = {
301
357
  },
302
358
 
303
359
  removeChildFromContainer(container, child) {
360
+ const roots = container._rootChildren;
361
+ const at = roots ? roots.indexOf(child) : -1;
362
+ if (at !== -1) roots.splice(at, 1);
363
+ // before the destroy, while the subtree is still walkable
364
+ a11yHooks.rootUnmounted?.(child);
304
365
  child.destroySubtree();
305
366
  },
306
367
 
@@ -338,6 +399,7 @@ const HostConfig = {
338
399
 
339
400
  detachDeletedInstance(instance) {
340
401
  instance.root?.events?.forget(instance);
402
+ instance.root?._forgetDropTarget?.(instance);
341
403
  },
342
404
 
343
405
  preparePortalMount() {},
@@ -401,107 +463,343 @@ const HostConfig = {
401
463
 
402
464
  export const Renderer = ReactReconciler(HostConfig);
403
465
 
404
- if (process.env.REACT_X11_DEVTOOLS) {
405
- // Install the DevTools hook before any React commit (top-level await:
406
- // module evaluation finishes before app code can call render) and
407
- // register the renderer with the standalone DevTools app.
408
- const devtools = await import('./DevToolsIntegration.js');
409
- await devtools.prepare();
410
- devtools.connect(Renderer);
411
- }
466
+ // So the event dispatcher can land a discrete-priority commit before it
467
+ // paints the response to that same input see flushSyncWork in priority.js.
468
+ setSyncFlush(() => Renderer.flushSyncWork());
412
469
 
413
- if (process.env.REACT_X11_CLICK_TO_COMPONENT || process.env.REACT_X11_EDITOR) {
414
- // Naming an editor already means you want the feature on — no need to
415
- // also set REACT_X11_CLICK_TO_COMPONENT=1 just to pick one.
416
- const clickToComponent = await import('./ClickToComponent.js');
417
- clickToComponent.install();
418
- }
470
+ let integrations = null;
471
+ let integrationsSettled = false;
419
472
 
420
- const roots = new Map();
421
- let cachedNtkApp = null;
473
+ /**
474
+ * The opt-in integrations, each behind its environment variable and each
475
+ * dynamically imported so a bundle only carries what it is asked for.
476
+ *
477
+ * This runs from `render()`/`createRoot()` rather than at module scope: a
478
+ * top-level await here would be inherited by every bundle containing
479
+ * react-x11, and esbuild cannot emit CommonJS for a graph that has one —
480
+ * which is what a Node single executable needs (see docs/packaging.md).
481
+ * The ordering guarantee is unchanged, because a React commit can only
482
+ * follow a root, and there is no way to obtain one without going through
483
+ * here first.
484
+ *
485
+ * Returns `null` when there is nothing to install — which is the normal
486
+ * case, and keeps `render(element, callback, container)` synchronous, as
487
+ * callers that read the tree straight after it expect.
488
+ */
489
+ function loadIntegrations() {
490
+ if (integrationsSettled) return null;
491
+ if (
492
+ !process.env.REACT_X11_DEVTOOLS &&
493
+ !process.env.REACT_X11_CLICK_TO_COMPONENT &&
494
+ !process.env.REACT_X11_EDITOR &&
495
+ !process.env.REACT_X11_TRACE
496
+ ) {
497
+ return null;
498
+ }
499
+ if (integrations) return integrations;
500
+ integrations = (async () => {
501
+ if (process.env.REACT_X11_DEVTOOLS) {
502
+ // Install the DevTools hook before any React commit, and register the
503
+ // renderer with the standalone DevTools app.
504
+ const devtools = await import('./DevToolsIntegration.js');
505
+ await devtools.prepare();
506
+ devtools.connect(Renderer);
507
+ }
508
+
509
+ if (
510
+ process.env.REACT_X11_CLICK_TO_COMPONENT ||
511
+ process.env.REACT_X11_EDITOR
512
+ ) {
513
+ // Naming an editor already means you want the feature on — no need to
514
+ // also set REACT_X11_CLICK_TO_COMPONENT=1 just to pick one.
515
+ const clickToComponent = await import('./ClickToComponent.js');
516
+ clickToComponent.install();
517
+ }
422
518
 
423
- async function connectApp() {
424
- if (cachedNtkApp) return cachedNtkApp;
519
+ if (process.env.REACT_X11_TRACE) {
520
+ // Protocol tracing (docs/debugging.md). debug.js writes files, which
521
+ // the playground bundle must not drag in. The trace itself attaches
522
+ // per connection, as each root registers the app it connected (or
523
+ // borrowed).
524
+ const debug = await import('./debug.js');
525
+ debug.startEnvTrace(process.env.REACT_X11_TRACE);
526
+ }
527
+ integrationsSettled = true;
528
+ })();
529
+ return integrations;
530
+ }
531
+
532
+ /**
533
+ * Open a connection this process owns. The wrapper is here for one reason:
534
+ * to say what is wrong when there is no server, which is the first thing
535
+ * anyone hits and the least self-explanatory failure in the library.
536
+ */
537
+ async function connect(options) {
425
538
  try {
426
- cachedNtkApp = await createClient();
539
+ return await createClient(options);
427
540
  } catch (err) {
541
+ const display = options.display ?? process.env.DISPLAY ?? '<unset>';
428
542
  throw new Error(
429
543
  'react-x11: could not connect to the X server. Is an X server running ' +
430
- `and DISPLAY set (DISPLAY=${process.env.DISPLAY || '<unset>'})? ` +
431
- 'Original error: ' +
544
+ `and DISPLAY set (DISPLAY=${display})? Original error: ` +
432
545
  err.message,
546
+ { cause: err },
433
547
  );
434
548
  }
435
- return cachedNtkApp;
436
549
  }
437
550
 
438
- function renderIntoContainer(element, container, callback) {
439
- let root = roots.get(container);
440
- if (!root) {
441
- root = Renderer.createContainer(
442
- container,
443
- ConcurrentRoot,
444
- null,
445
- false,
446
- null,
447
- '',
448
- (error) => console.error('react-x11: uncaught error', error),
449
- (error) => console.error('react-x11: caught error', error),
450
- (error) => console.error('react-x11: recoverable error', error),
451
- null,
452
- );
453
- roots.set(container, root);
454
- }
551
+ /** An ntk App, told apart from an options bag by what only an App has. */
552
+ const isNtkApp = (v) =>
553
+ Boolean(v) && typeof v.createWindow === 'function' && typeof v.X === 'object';
554
+
555
+ // What a root that opens its own connection forwards to ntk. `stream` is
556
+ // how you reach a server that is not on the other end of $DISPLAY — an
557
+ // in-process one, a tunnel — and is what the tests connect through.
558
+ const CONNECT_OPTIONS = [
559
+ 'display',
560
+ 'stream',
561
+ 'fontSource',
562
+ 'glxVisual',
563
+ // which OpenGL backend <glarea> draws through: 'indirect' (ntk's
564
+ // default), 'auto', 'direct' or 'off'. It has to be passed at connect time
565
+ // rather than set later, because ntk probes for the direct backend during
566
+ // the handshake — see docs/gl.md.
567
+ 'glPolicy',
568
+ 'onXError',
569
+ ];
455
570
 
456
- Renderer.updateContainerSync(element, root, null, () => {
457
- const publicInstance = Renderer.getPublicRootInstance(root);
458
- if (callback) {
459
- callback(publicInstance, container);
460
- }
461
- });
462
- Renderer.flushSyncWork();
463
- }
571
+ const DEFAULT_ERROR_HANDLERS = defaultRootHandlers;
464
572
 
465
573
  /**
466
- * Legacy entry point. Without a container it connects to the X server
467
- * (returns a promise in that case).
574
+ * The connection ended without us asking. `end` is the stream closing —
575
+ * server exit, ssh drop, kill. An `error` may be either a transport failure
576
+ * or an X protocol error ntk already reports through `onXError`; only the
577
+ * former ends the connection, and only it carries no `majorOpcode`.
468
578
  */
469
- export function render(element, callback, container) {
470
- if (!container) {
471
- return connectApp().then((app) =>
472
- renderIntoContainer(element, app, callback),
473
- );
474
- }
475
- return renderIntoContainer(element, container, callback);
579
+ function watchConnection(app, onDisconnect, deliberate) {
580
+ let done = false;
581
+ const fire = (reason, err) => {
582
+ if (done || deliberate()) return;
583
+ done = true;
584
+ onDisconnect(reason, err);
585
+ };
586
+ app.X.on('end', () => fire('closed'));
587
+ app.X.on('error', (err) => {
588
+ if (err?.majorOpcode === undefined) fire('error', err);
589
+ });
476
590
  }
477
591
 
478
592
  /**
479
- * Modern entry point:
593
+ * The entry point:
480
594
  *
481
- * const root = await createRoot(); // connects via DISPLAY
595
+ * const root = await createRoot(); // connects via $DISPLAY
596
+ * const root = await createRoot({ display: ':1' });
597
+ * const root = await createRoot({ app }); // a connection you have
482
598
  * root.render(<App />);
599
+ * await root.unmount();
600
+ *
601
+ * Every root without `app` opens **its own** connection and owns it, so two
602
+ * roots are two independent trees; `unmount()` closes what it opened. A root
603
+ * given an `app` borrows it and never closes it — that connection belongs to
604
+ * whoever made it.
483
605
  *
484
- * Pass an ntk App (or a mock) to render into an existing connection.
606
+ * `display`, `fontSource`, `glxVisual` and `onXError` go straight to ntk.
607
+ * Anything else ntk understands, build the client yourself and pass `app`.
485
608
  */
486
- export async function createRoot(container) {
487
- const app = container ?? (await connectApp());
488
- return {
609
+ export async function createRoot(options = {}) {
610
+ // Before anything builds a node: every drawn node creates a yoga node in
611
+ // its constructor, and the engine's WebAssembly is loaded rather than
612
+ // imported (src/yoga.js — a top-level await here would cost every app the
613
+ // single-executable build). ntk's createClient used to do this while the
614
+ // engine was still ntk's, which also covered `createRoot({ app })`; it is
615
+ // ours now, so this is the one place that has to know. It runs alongside the
616
+ // connection rather than before it — neither needs the other — though "I/O"
617
+ // flatters the engine: most of that load is the loop-blocking instantiate,
618
+ // which is why the order below is what it is.
619
+ //
620
+ // The misuse check comes before anything starts, so a bad call cannot leave
621
+ // a load or a connection in flight with nothing waiting on it.
622
+ if (isNtkApp(options)) {
623
+ throw new Error(
624
+ 'react-x11: createRoot takes an options object — pass the connection ' +
625
+ 'as createRoot({ app }).',
626
+ );
627
+ }
628
+ const { app: borrowed, onDisconnect, ...rest } = options;
629
+ const owned = borrowed === undefined;
630
+ // The connection is started first, and the order is the point rather than a
631
+ // detail. `loadLayout()` is only nominally asynchronous: instantiating the
632
+ // engine blocks the event loop for 15-50 ms before it returns its promise
633
+ // (measured), so calling it first delays the socket work by exactly that —
634
+ // the loop cannot run the connect callback that writes the hello while the
635
+ // instantiate is on it. Starting the connection first puts the handshake in
636
+ // flight, and the block then overlaps it instead of preceding it.
637
+ //
638
+ // Worth being honest about the size: the most this can save is the
639
+ // synchronous block, and only where the handshake takes longer than it. It
640
+ // is not measurable on a Unix socket, and it is lost in the noise on a link
641
+ // slow enough to matter. This is the right order, not a fast one.
642
+ const connecting = owned
643
+ ? connect(
644
+ Object.fromEntries(
645
+ CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
646
+ k,
647
+ rest[k],
648
+ ]),
649
+ ),
650
+ )
651
+ : Promise.resolve(borrowed);
652
+ const layout = loadLayout();
653
+ const integrations = loadIntegrations(); // null when there is nothing to install
654
+ const [app] = await Promise.all([connecting, layout, integrations]);
655
+
656
+ const container = Renderer.createContainer(
657
+ app,
658
+ ConcurrentRoot,
659
+ null,
660
+ false,
661
+ null,
662
+ '',
663
+ rest.onUncaughtError ?? DEFAULT_ERROR_HANDLERS.onUncaughtError,
664
+ rest.onCaughtError ?? DEFAULT_ERROR_HANDLERS.onCaughtError,
665
+ rest.onRecoverableError ?? DEFAULT_ERROR_HANDLERS.onRecoverableError,
666
+ null,
667
+ );
668
+
669
+ // A throw from an event handler has no React on the stack, so it never
670
+ // reaches the container above — the root's handler is reached through the
671
+ // container instead, which is what the nodes carry.
672
+ if (rest.onUncaughtError) setErrorHandler(app, rest.onUncaughtError);
673
+
674
+ let unmounted = false;
675
+ if (onDisconnect) watchConnection(app, onDisconnect, () => unmounted);
676
+
677
+ // borrowed or owned, this is now a connection the renderer draws through,
678
+ // which is what a REACT_X11_TRACE / startTrace() session follows
679
+ registerApp(app);
680
+
681
+ // Climb toward the accessibility bus, once per process and off the
682
+ // critical path — every rung that fails is a normal, silent "off"
683
+ // (docs/accessibility.md). Deliberately not awaited: a root must not
684
+ // wait on a bus that is not there.
685
+ startA11y();
686
+
687
+ // Before anything renders: the launch id has to be on the first toplevel
688
+ // before it maps, and the environment variable has to be consumed whether
689
+ // or not this app ends up using it (src/startup.js).
690
+ beginStartup(app, rest.startupNotification);
691
+
692
+ // Which dead-key and Compose sequences this root types (src/compose.js).
693
+ // Synchronous and normally free: the built-in table is built once per
694
+ // process, and only `compose: 'system'` or a file of your own reads
695
+ // anything from disk.
696
+ beginCompose(app, rest.compose);
697
+
698
+ // Whether a subtree coming back out of hiding — a `<Suspense>` boundary
699
+ // resolving, an `<Activity>` shown again — takes the keyboard back with it
700
+ // (src/events.js, `subtreeRevealed`). On by default; `false` is the
701
+ // browser's answer, where focus stays wherever the hide dropped it.
702
+ beginFocus(app, rest.restoreFocusOnReveal);
703
+
704
+ // Which keysym a shortcut matches when the layout is not Latin
705
+ // (src/keyboard.js). Pure bookkeeping — the work happens per key event,
706
+ // and only for a key that types another script.
707
+ beginKeyboard(app, rest.accelerators);
708
+
709
+ // Awaited, and this is the only place it can be: whether a compositor is
710
+ // running decides what a `transparent` window paints, and a window that
711
+ // realized before the answer landed would paint the wrong thing once and
712
+ // correct itself visibly — and the same shape of question twice more. The
713
+ // display scale multiplies every style length, every font size and every
714
+ // CreateWindow rectangle, so it must be settled before the first node
715
+ // resolves a style — there is no "correct it a frame later" for a tree
716
+ // that laid out at half size; normally free or one round trip (the
717
+ // environment answers, or XSETTINGS does), with the RandR hardware walk
718
+ // only paid on desktops where nothing cheaper answered (src/scale.js).
719
+ // And a `<window>` with no size given is sized from its content and
720
+ // capped at the screen, where the cap has to be a *synchronous* answer
721
+ // because the size is resolved before CreateWindow. The three probes are
722
+ // independent conversations with the server (a selection owner and an
723
+ // extension; the scale ladder; the monitor layout and the work-area
724
+ // property), so they run concurrently: each is internally a chain of one
725
+ // to three round trips, and awaiting them in sequence made every app pay
726
+ // the sum where the slowest chain is the true floor.
727
+ await Promise.all([
728
+ beginCompositing(app),
729
+ beginScale(app, rest.scale),
730
+ beginScreens(app),
731
+ ]);
732
+
733
+ // How fast a caret blinks, how long a double click has, how far a press
734
+ // moves before it is a drag. **Not** awaited, unlike the two above: the
735
+ // renderer reads these synchronously but only on an interaction — a focus,
736
+ // a click, a drag — which is always many milliseconds after startup, so
737
+ // making every app wait five round trips for them would buy nothing. A
738
+ // field focused on the very first frame gets the built-in defaults and the
739
+ // desktop's cadence from its next focus on (src/desktopsettings.js).
740
+ beginDesktopSettings(app);
741
+
742
+ // The desktop switching between light and dark reaches the widgets through
743
+ // React — `useTheme()` subscribes — but the other theme route is the node
744
+ // tree, which React does not re-render. This is that half: drop the cached
745
+ // palettes and repaint, above all the window background, which is read from
746
+ // the palette at paint time. Not awaited, and it starts nothing: the store
747
+ // is seeded from disk and the ladder runs when something asks.
748
+ const stopAppearance = watchAppearance(() => appearanceChanged(app));
749
+
750
+ const root = {
489
751
  app,
490
752
  render(element, callback) {
491
- renderIntoContainer(element, app, callback);
753
+ // The provider wraps rather than replaces: it renders no host node, so
754
+ // `getPublicRootInstance` still hands back the tree's own root and
755
+ // nothing downstream can tell it is there. `null` unmounts, and must
756
+ // stay null rather than become a provider around nothing.
757
+ // The provider renders no host node, so it changes nothing about
758
+ // what is drawn — but it does sit between the root fiber and the
759
+ // tree, which is why the public instance comes from the container's
760
+ // own record rather than from getPublicRootInstance. `null` unmounts,
761
+ // and has to stay null rather than become a provider around nothing.
762
+ const tree =
763
+ element == null
764
+ ? element
765
+ : React.createElement(AppProvider, { value: app }, element);
766
+ Renderer.updateContainerSync(tree, container, null, () => {
767
+ const rootNode = app._rootChildren?.[0] ?? null;
768
+ callback?.(rootNode && HostConfig.getPublicInstance(rootNode), app);
769
+ });
770
+ Renderer.flushSyncWork();
492
771
  },
493
- unmount() {
494
- unmountComponentAtNode(app);
772
+ /** Unmounts, then closes the connection unless `app` was passed in. */
773
+ async unmount() {
774
+ if (unmounted) return;
775
+ unmounted = true;
776
+ Renderer.updateContainerSync(null, container, null, null);
777
+ Renderer.flushSyncWork();
778
+ if (rest.onUncaughtError) setErrorHandler(app, null);
779
+ stopAppearance();
780
+ endCompositing(app);
781
+ endScreens(app);
782
+ endDesktopSettings(app);
783
+ endIdle(app);
784
+ endKeyboardState(app);
785
+ // The XSETTINGS session outlives both of its readers otherwise: it
786
+ // holds a PropertyChange selection on the settings daemon's window and
787
+ // an XFixes watch on the selection, neither of which anything else
788
+ // takes off.
789
+ endXSettings(app);
790
+ if (owned) {
791
+ unregisterApp(app);
792
+ await app.close();
793
+ }
794
+ if (app._reactX11Root === root) app._reactX11Root = null;
495
795
  },
496
796
  };
497
- }
498
797
 
499
- export function unmountComponentAtNode(container) {
500
- const root = roots.get(container);
501
- if (root) {
502
- Renderer.updateContainerSync(null, root, null, () => {
503
- roots.delete(container);
504
- });
505
- Renderer.flushSyncWork();
506
- }
798
+ // What a WM close request with no onCloseRequest unmounts (see
799
+ // WindowNode#_defaultCloseRequest). The first root wins, because it is the
800
+ // one whose window the request will be arriving on; a second root sharing a
801
+ // borrowed connection owns its own teardown either way.
802
+ app._reactX11Root ??= root;
803
+
804
+ return root;
507
805
  }