react-x11 2.1.2 → 2.1.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -11,14 +11,44 @@
11
11
  // original source — we only have to parse the stack text and skip the
12
12
  // frames inside React/the reconciler itself.
13
13
  import { spawn } from 'node:child_process';
14
+ import { dirname, sep } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { setClickToComponentHandler } from './events.js';
16
17
  import { selectInDevTools } from './DevToolsIntegration.js';
17
18
 
18
19
  const STACK_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
19
20
 
21
+ // The frames to walk past before the JSX call site itself: React captures
22
+ // the Error inside `jsxDEV`/`createElement`, and an element created by this
23
+ // renderer (or by node internals) is never what a click means. `SELF_DIR` is
24
+ // whichever copy of react-x11 is running — the repo's own `src/` for the
25
+ // examples and tests, an installed `node_modules/react-x11/src` for an app.
26
+ //
27
+ // Everything *else* in `node_modules` is deliberately not skipped here: a
28
+ // frame inside an installed package is a real call site, it is just not one
29
+ // in the application's source, and telling the two apart is what lets
30
+ // `resolveOwnedLocation` climb to the owner that is. Skipping every
31
+ // `node_modules` frame instead would walk straight past the library
32
+ // component and land on whatever application frame happens to be deeper in
33
+ // the render stack — usually the `render()` call at startup, which looks
34
+ // like an answer and isn't.
35
+ const REACT_RUNTIME =
36
+ /[\\/]node_modules[\\/](react|react-dom|react-reconciler|scheduler)[\\/]/;
37
+ const SELF_DIR = dirname(fileURLToPath(import.meta.url)) + sep;
38
+
39
+ function isInternalFrame(file) {
40
+ return (
41
+ file.startsWith('node:') ||
42
+ file === '<anonymous>' ||
43
+ file.startsWith(SELF_DIR) ||
44
+ REACT_RUNTIME.test(file)
45
+ );
46
+ }
47
+
20
48
  /** The first stack frame outside React/the reconciler/node internals — the
21
- * user's own JSX call site for whatever element this Error was captured at.
49
+ * JSX call site for whatever element this Error was captured at, wherever it
50
+ * was written. `installed` marks a call site that lives inside an installed
51
+ * package rather than in the application's own source.
22
52
  * Exported because `react-x11/test`'s `sourceOf` answers the same question
23
53
  * for a test that a click answers for an editor. */
24
54
  export function resolveLocation(debugStack) {
@@ -28,26 +58,54 @@ export function resolveLocation(debugStack) {
28
58
  const match = line.match(STACK_FRAME);
29
59
  if (!match) continue;
30
60
  const [, functionName, rawFile, lineStr, columnStr] = match;
31
- if (
32
- rawFile.includes('/node_modules/') ||
33
- rawFile.startsWith('node:') ||
34
- rawFile === '<anonymous>'
35
- ) {
36
- continue;
37
- }
38
61
  const file = rawFile.startsWith('file://')
39
62
  ? fileURLToPath(rawFile)
40
63
  : rawFile;
64
+ if (isInternalFrame(file)) continue;
41
65
  return {
42
66
  functionName,
43
67
  file,
44
68
  line: Number(lineStr),
45
69
  column: Number(columnStr),
70
+ installed: file.includes(`${sep}node_modules${sep}`),
46
71
  };
47
72
  }
48
73
  return null;
49
74
  }
50
75
 
76
+ /** The nearest source location a click can mean: the clicked element's own
77
+ * JSX call site when the application wrote it, and otherwise the first owner
78
+ * up the chain that it did write. An element rendered by an installed
79
+ * component — a design system's `<Toolbar>`, a chart library's internals —
80
+ * has its call site inside that package; what the user meant by clicking it
81
+ * is the `<Toolbar ... />` line in their own file that put it on screen.
82
+ *
83
+ * Returns `{ fiber, location, depth }`, `depth` being how many owners up the
84
+ * location came from (0 = the clicked element's own). Falls back to the
85
+ * nearest installed call site when nothing in the chain is application
86
+ * source — opening a package's own file beats refusing to open anything —
87
+ * and null only when React has no debug info at all. */
88
+ export function resolveOwnedLocation(fiber) {
89
+ let fallback = null;
90
+ let depth = 0;
91
+ for (let owner = fiber; owner; owner = owner._debugOwner, depth++) {
92
+ const location = resolveLocation(owner._debugStack);
93
+ if (!location) continue;
94
+ if (!location.installed) return { fiber: owner, location, depth };
95
+ fallback ??= { fiber: owner, location, depth };
96
+ }
97
+ return fallback;
98
+ }
99
+
100
+ /** What the clicked thing *is* — `<box>` for a host node, the component's
101
+ * name for a composite one. (`componentName` below answers a different
102
+ * question: who *wrote* it.) */
103
+ function elementLabel(fiber) {
104
+ const type = fiber.type;
105
+ if (typeof type === 'string') return `<${type}>`;
106
+ return type?.displayName || type?.name || '(anonymous)';
107
+ }
108
+
51
109
  function componentName(fiber) {
52
110
  const owner = fiber._debugOwner;
53
111
  const type = owner?.type;
@@ -119,13 +177,39 @@ function openInEditor({ file, line, column }) {
119
177
  bin = OPEN_URI_COMMAND;
120
178
  args = [editorUri(scheme, file, line, column)];
121
179
  }
122
- const child = spawn(bin, args, { detached: true, stdio: 'ignore' });
180
+ // stderr is kept (rather than the whole stdio ignored) for one reason:
181
+ // `open`/`xdg-open` exit non-zero and print *there* when nothing claims
182
+ // the scheme — an editor that isn't installed, or one whose URL handler
183
+ // was never registered. Swallowing that is what makes click-to-component
184
+ // look like it only logs: the location resolves, the console line prints,
185
+ // and the editor silently never opens.
186
+ const child = spawn(bin, args, {
187
+ detached: true,
188
+ stdio: ['ignore', 'ignore', 'pipe'],
189
+ });
190
+ let stderr = '';
191
+ // ...and unref'd, so keeping it does not keep the process alive: a pipe is
192
+ // a ref'd handle, and an editor that outlives the app it was launched from
193
+ // is the normal case, not a reason to hold the event loop open.
194
+ child.stderr.unref();
195
+ child.stderr.on('data', (chunk) => {
196
+ stderr += chunk;
197
+ });
123
198
  child.on('error', (err) => {
124
199
  console.warn(
125
200
  `react-x11: click-to-component could not launch "${bin}" (${err.code ?? err.message}). ` +
126
201
  'Set REACT_X11_EDITOR to your editor CLI (cursor, code, code-insiders, windsurf, vim, nvim).',
127
202
  );
128
203
  });
204
+ child.on('exit', (code) => {
205
+ if (!code) return;
206
+ console.warn(
207
+ `react-x11: click-to-component — \`${bin} ${args.join(' ')}\` exited ${code}. ` +
208
+ (stderr.trim() ? `${stderr.trim()} ` : '') +
209
+ `Set REACT_X11_EDITOR to the editor you actually use (currently "${name}"; ` +
210
+ 'cursor, code, code-insiders, windsurf, vim, nvim, or any registered URI scheme).',
211
+ );
212
+ });
129
213
  child.unref();
130
214
  }
131
215
 
@@ -146,17 +230,29 @@ function handleClick(node, native) {
146
230
  );
147
231
  return;
148
232
  }
149
- const location = resolveLocation(fiber._debugStack);
150
- if (!location) {
233
+ const resolved = resolveOwnedLocation(fiber);
234
+ if (!resolved) {
151
235
  console.warn(
152
- 'react-x11: click-to-component — no source location found. This needs ' +
153
- 'React running in development mode (fiber._debugStack).',
236
+ 'react-x11: click-to-component — no source location found for this ' +
237
+ 'element or any of its owners. This needs React running in ' +
238
+ 'development mode (fiber._debugStack).',
154
239
  );
155
240
  return;
156
241
  }
242
+ const { fiber: sourceFiber, location, depth } = resolved;
243
+ // What was clicked is not always what has a source: say so rather than
244
+ // silently opening a file the click doesn't obviously correspond to.
245
+ const climbed =
246
+ depth > 0
247
+ ? ` (${depth} owner${depth > 1 ? 's' : ''} up from the clicked ` +
248
+ `${elementLabel(fiber)})`
249
+ : '';
250
+ const installed = location.installed ? ' — inside an installed package' : '';
157
251
  console.log(
158
- `[click-to-component] ${componentName(fiber)} → ` +
159
- `${location.file}:${location.line}:${location.column}`,
252
+ `[click-to-component] ${componentName(sourceFiber)} → ` +
253
+ `${location.file}:${location.line}:${location.column}` +
254
+ climbed +
255
+ installed,
160
256
  );
161
257
  if (native?.buttons & 1) {
162
258
  // Alt+Shift+Click
@@ -340,6 +340,13 @@ export class ForeignNode extends Node {
340
340
  this._syncGeometry();
341
341
  }
342
342
 
343
+ // the scroll fast path moves `abs` without coming through absolutize
344
+ // (issue #405), and the embedded window has to follow it all the same
345
+ _shiftAbs(dx, dy) {
346
+ super._shiftAbs(dx, dy);
347
+ this._syncGeometry();
348
+ }
349
+
343
350
  _syncGeometry() {
344
351
  const socket = this.socket;
345
352
  if (!socket) return;
package/src/glnodes.js CHANGED
@@ -230,6 +230,13 @@ export class GlAreaNode extends Node {
230
230
  this._syncGeometry();
231
231
  }
232
232
 
233
+ // the scroll fast path moves `abs` without coming through absolutize
234
+ // (issue #405), and the real X window has to follow it all the same
235
+ _shiftAbs(dx, dy) {
236
+ super._shiftAbs(dx, dy);
237
+ this._syncGeometry();
238
+ }
239
+
233
240
  _syncGeometry() {
234
241
  const wnd = this.window;
235
242
  if (!wnd) return;
package/src/nodes.js CHANGED
@@ -3261,6 +3261,74 @@ export class Node {
3261
3261
  }
3262
3262
  }
3263
3263
 
3264
+ /**
3265
+ * Move an already-laid-out subtree by a constant, without asking yoga
3266
+ * anything — the scroll fast path's walk (issue #405).
3267
+ *
3268
+ * A pure-scroll frame changes nothing about the arrangement inside a
3269
+ * viewport: every descendant sits exactly where the last pass put it,
3270
+ * shifted by the scroll delta. `absolutize` would re-derive each rect
3271
+ * through four wasm-boundary getters to learn what one addition already
3272
+ * says, so the scroller calls this instead — only after proving nothing
3273
+ * inside was laid out this pass (see `_absolutizeChildren`).
3274
+ *
3275
+ * `abs` is adjusted in place rather than replaced: its identity is
3276
+ * already long-lived (`_assignAbs` keeps the object whenever a rect is
3277
+ * unchanged), and everything that records a rect for later copies it.
3278
+ * The cached hit bounds ride along instead of being dropped — a uniform
3279
+ * translation is the one change a cached union survives — which keeps a
3280
+ * wheel flick from rebuilding the pane's whole hit-bounds tree per notch.
3281
+ *
3282
+ * No layout diff runs here, and none is owed: under a blit ledger the
3283
+ * shifted diff's claims are the *deviations* from exactly this
3284
+ * translation, and a subtree nothing laid out again has none.
3285
+ */
3286
+ _shiftAbs(dx, dy) {
3287
+ if (!this.yoga) return;
3288
+ const abs = this.abs;
3289
+ abs.x += dx;
3290
+ abs.y += dy;
3291
+ const b = this._hitBoundsCache;
3292
+ if (b) {
3293
+ b.left += dx;
3294
+ b.right += dx;
3295
+ b.top += dy;
3296
+ b.bottom += dy;
3297
+ }
3298
+ this._shiftChildren(dx, dy);
3299
+ }
3300
+
3301
+ /** Split from `_shiftAbs` so a scroller can reroute its children through
3302
+ * its own offset bookkeeping — the box moves rigidly, but the children's
3303
+ * origin also carries scroll offsets that may have changed again this
3304
+ * same frame (`Scrollable._shiftChildren`). */
3305
+ _shiftChildren(dx, dy) {
3306
+ for (const child of this.children) {
3307
+ if (!child.isWindow) child._shiftAbs(dx, dy);
3308
+ }
3309
+ }
3310
+
3311
+ /**
3312
+ * A layout-affecting change at this node may change how far the content
3313
+ * of an enclosing scroll pane reaches through a route yoga never
3314
+ * witnesses — an element that paints its own content growing its extent
3315
+ * announces it with `invalidate(true, this, 'scroll')`
3316
+ * (docs/extending.md), and no yoga node is dirtied by that. Mark every
3317
+ * scroller whose measurement can see this node, so the next pass asks
3318
+ * `measureScrollContent` again instead of reusing the cached reach
3319
+ * (issue #405). The walk stops where the measurement does: at the first
3320
+ * ancestor that clips its children, whose overflow is its own business.
3321
+ */
3322
+ _markScrollMeasureDirty() {
3323
+ for (let n = this; n; n = n.parent) {
3324
+ // only a Scrollable carries the flag; a stale `true` on a box that is
3325
+ // not currently a scroller costs nothing and re-measures correctly if
3326
+ // its style later makes it one
3327
+ if (n._scrollMeasureDirty === false) n._scrollMeasureDirty = true;
3328
+ if (n !== this && n.clipsChildren()) return;
3329
+ }
3330
+ }
3331
+
3264
3332
  /**
3265
3333
  * Ask the owning window to repaint. The damage lives on the window node,
3266
3334
  * which is the only node with a frame clock — this forwards there, so an
@@ -3274,6 +3342,9 @@ export class Node {
3274
3342
  * the mount invalidates in full anyway.
3275
3343
  */
3276
3344
  invalidate(layoutChanged = false, damage = null, reason = null) {
3345
+ // a layout change may grow what an enclosing scroll pane has to scroll,
3346
+ // through a route yoga never sees (issue #405)
3347
+ if (layoutChanged) this._markScrollMeasureDirty();
3277
3348
  this.root?.invalidate(layoutChanged, damage, reason);
3278
3349
  }
3279
3350
 
@@ -3429,6 +3500,7 @@ export class Node {
3429
3500
  * FULL_DAMAGE the way a bare `invalidate(true, null)` would.
3430
3501
  */
3431
3502
  _invalidateLayout(reason) {
3503
+ this._markScrollMeasureDirty();
3432
3504
  const root = this.root;
3433
3505
  if (!root) return;
3434
3506
  // Same walk, same frame, same answer — see `_childListBefore`, whose
@@ -5279,6 +5351,10 @@ export const Scrollable = (Base) =>
5279
5351
  this.scrollX = 0;
5280
5352
  this.contentHeight = 0;
5281
5353
  this.contentWidth = 0;
5354
+ // `measureScrollContent` owed a fresh answer — true until the first
5355
+ // layout pass measures, and re-raised by any change yoga cannot see
5356
+ // (`_markScrollMeasureDirty`, issue #405)
5357
+ this._scrollMeasureDirty = true;
5282
5358
  }
5283
5359
 
5284
5360
  /**
@@ -5301,6 +5377,9 @@ export const Scrollable = (Base) =>
5301
5377
  * position the same way when a box stops scrolling.
5302
5378
  */
5303
5379
  _overflowChanged() {
5380
+ // whichever way the style flipped, the next scrolling pass starts
5381
+ // from a fresh measurement
5382
+ this._scrollMeasureDirty = true;
5304
5383
  if (this.isScroller()) return;
5305
5384
  this._scrollIntoViewTarget = null;
5306
5385
  this._childOrigin = null;
@@ -5335,20 +5414,39 @@ export const Scrollable = (Base) =>
5335
5414
  return;
5336
5415
  }
5337
5416
  const rtl = this.direction === 'rtl';
5338
- const size = this.measureScrollContent();
5339
- if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
5340
- // A NaN here does not throw on its own: it becomes a NaN max scroll,
5341
- // a NaN offset, and every child laid out at NaN a whole tree gone
5342
- // with nothing naming the element that did it.
5343
- throw new Error(
5344
- `react-x11: <${this.kind}>.measureScrollContent() must return ` +
5345
- '{ width, height } as finite numbers; it returned ' +
5346
- `${describeSize(size)}. Return { width: 0, height: 0 } for ` +
5347
- 'content that has not arrived yet.',
5348
- );
5417
+ // A pure-scroll pass re-learns nothing by walking (issue #405): the
5418
+ // content reach and every child's place *inside* the pane only change
5419
+ // when layout inside the pane changes. Yoga's own has-new-layout flag
5420
+ // is the witness consumed here and nowhere elseset by any pass
5421
+ // that laid this node or anything under it out again, and left clear
5422
+ // by one that merely scrolled. `_scrollMeasureDirty` covers the one
5423
+ // route yoga cannot see: an element that paints its own content
5424
+ // growing its extent (docs/extending.md), announced through
5425
+ // `invalidate(true, this, 'scroll')`. The root's yoga node re-flags
5426
+ // on every pass, so a `<window overflow='scroll'>` always takes the
5427
+ // full walk — the pane that holds an app's long list is a box.
5428
+ const clean =
5429
+ this._childOrigin != null &&
5430
+ !this._scrollMeasureDirty &&
5431
+ !this.yoga.hasNewLayout();
5432
+ if (!clean) {
5433
+ const size = this.measureScrollContent();
5434
+ if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
5435
+ // A NaN here does not throw on its own: it becomes a NaN max
5436
+ // scroll, a NaN offset, and every child laid out at NaN — a whole
5437
+ // tree gone with nothing naming the element that did it.
5438
+ throw new Error(
5439
+ `react-x11: <${this.kind}>.measureScrollContent() must return ` +
5440
+ '{ width, height } as finite numbers; it returned ' +
5441
+ `${describeSize(size)}. Return { width: 0, height: 0 } for ` +
5442
+ 'content that has not arrived yet.',
5443
+ );
5444
+ }
5445
+ this.contentWidth = size.width;
5446
+ this.contentHeight = size.height;
5447
+ this._scrollMeasureDirty = false;
5448
+ this.yoga.markLayoutSeen();
5349
5449
  }
5350
- this.contentWidth = size.width;
5351
- this.contentHeight = size.height;
5352
5450
  this._resolveScrollIntoView();
5353
5451
  this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
5354
5452
  this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
@@ -5373,6 +5471,23 @@ export const Scrollable = (Base) =>
5373
5471
  const wasOrigin = this._childOrigin;
5374
5472
  const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
5375
5473
  this._childOrigin = { x: ox, y: oy };
5474
+ if (clean) {
5475
+ // The fast path (issue #405): nothing inside was laid out, so every
5476
+ // child sits exactly where the last pass put it, shifted by however
5477
+ // far the origin moved — one uniform translation instead of a
5478
+ // per-node yoga re-derivation. The layout diff is owed nothing by
5479
+ // construction: under a blit ledger the shifted diff's claims are
5480
+ // the deviations from this very translation, and a clean pane has
5481
+ // none — the walk below lands every node where the diff would have
5482
+ // reported silence.
5483
+ if (!shifted) return;
5484
+ const dx = ox - wasOrigin.x;
5485
+ const dy = oy - wasOrigin.y;
5486
+ for (const child of this.children) {
5487
+ if (!child.isWindow) child._shiftAbs(dx, dy);
5488
+ }
5489
+ return;
5490
+ }
5376
5491
  const outer = layoutDiffSink;
5377
5492
  const outerShift = layoutDiffShift;
5378
5493
  const ledger = shifted && this._blitLedgerOpen();
@@ -5416,6 +5531,22 @@ export const Scrollable = (Base) =>
5416
5531
  }
5417
5532
  }
5418
5533
 
5534
+ /**
5535
+ * A scroller inside a shifting subtree does not ride the translation
5536
+ * blindly: its box moves rigidly, but its children's origin also
5537
+ * carries the scroll offsets, which may have changed again this very
5538
+ * frame — a wheel on a nested pane while an outer one scrolls.
5539
+ * Re-entering `_absolutizeChildren` folds both into one delta, and
5540
+ * re-runs the gate, so a nested pane that is not clean still walks
5541
+ * properly. (Reached only under an outer pane's fast path, which
5542
+ * proved nothing in here was laid out — the nested gate can only
5543
+ * decline over its own `_scrollMeasureDirty`.)
5544
+ */
5545
+ _shiftChildren(dx, dy) {
5546
+ if (!this.isScroller()) return super._shiftChildren(dx, dy);
5547
+ this._absolutizeChildren(this.abs.x, this.abs.y);
5548
+ }
5549
+
5419
5550
  /**
5420
5551
  * Tell the owner how big the viewport and the content turned out, when
5421
5552
  * either changes. Layout happens on the frame clock, *after* the commit
@@ -5477,8 +5608,14 @@ export const Scrollable = (Base) =>
5477
5608
  * wheel, the scrollbars, the scroll keys and the AT-SPI scroll pane all
5478
5609
  * read the numbers this returns (docs/extending.md).
5479
5610
  *
5480
- * Called once per layout pass, from `absolutize`, so it may read yoga
5481
- * geometry but must not invalidate or paint.
5611
+ * Called at most once per layout pass, from `absolutize`, so it may
5612
+ * read yoga geometry but must not invalidate or paint — and cached
5613
+ * across passes that laid nothing inside the pane out again (issue
5614
+ * #405): a pass that merely scrolled reuses the last answer, since a
5615
+ * scroll cannot change how far the content reaches. An element whose
5616
+ * extent changed by a route layout never saw — rows arrived, a line
5617
+ * was typed — announces it with `invalidate(true, this, 'scroll')`,
5618
+ * and the next pass asks again.
5482
5619
  */
5483
5620
  measureScrollContent() {
5484
5621
  const rtl = this.direction === 'rtl';