react-x11 2.1.1 → 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 +1 -1
- package/src/ClickToComponent.js +111 -15
- package/src/foreignnodes.js +7 -0
- package/src/glnodes.js +7 -0
- package/src/nodes.js +182 -24
package/package.json
CHANGED
package/src/ClickToComponent.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
150
|
-
if (!
|
|
233
|
+
const resolved = resolveOwnedLocation(fiber);
|
|
234
|
+
if (!resolved) {
|
|
151
235
|
console.warn(
|
|
152
|
-
'react-x11: click-to-component — no source location found
|
|
153
|
-
'React running in
|
|
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(
|
|
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
|
package/src/foreignnodes.js
CHANGED
|
@@ -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
|
@@ -2244,8 +2244,19 @@ export class Node {
|
|
|
2244
2244
|
}
|
|
2245
2245
|
|
|
2246
2246
|
/** The theme above or on this node changed: drop the caches and restyle
|
|
2247
|
-
* the subtree, since a token can appear at any depth.
|
|
2248
|
-
|
|
2247
|
+
* the subtree, since a token can appear at any depth.
|
|
2248
|
+
*
|
|
2249
|
+
* `mounting` is `insertBefore` attaching a subtree that has never been in
|
|
2250
|
+
* the tree: the walk still resolves every token — the nodes can see their
|
|
2251
|
+
* ancestors now — but it claims no damage (issue #402). A node that has
|
|
2252
|
+
* never painted has no stale pixels to cover, and the rect it is about to
|
|
2253
|
+
* occupy is claimed by the child-list/layout-diff protocol like any other
|
|
2254
|
+
* inserted child's; the unbounded claims below would turn every commit
|
|
2255
|
+
* that mounts a token-styled node into a full-window repaint — which is
|
|
2256
|
+
* every re-slice of a virtualized list whose rows follow the palette. A
|
|
2257
|
+
* live theme *swap* is the other caller and keeps them: it moves pixels
|
|
2258
|
+
* that are already on screen, anywhere in the subtree. */
|
|
2259
|
+
_themeChanged(mounting = false) {
|
|
2249
2260
|
// This walk visits every node itself, so the per-node re-resolution below
|
|
2250
2261
|
// is enough — a style swap it causes must not start a second walk of the
|
|
2251
2262
|
// same subtree from halfway down.
|
|
@@ -2269,19 +2280,23 @@ export class Node {
|
|
|
2269
2280
|
if (localTextStyleChanged(this.style, before)) {
|
|
2270
2281
|
this._textContentChanged();
|
|
2271
2282
|
}
|
|
2272
|
-
this.root?.invalidate(true, null, 'theme');
|
|
2283
|
+
if (!mounting) this.root?.invalidate(true, null, 'theme');
|
|
2273
2284
|
}
|
|
2274
2285
|
// The palette is the floor under the cascade, so a theme swap moves the
|
|
2275
2286
|
// resolved style of every node that named none of its own — and none of
|
|
2276
2287
|
// that is in a style object, so nothing above would have noticed. A
|
|
2277
2288
|
// swap that only changes `fontFamily` is the case that made this worth
|
|
2278
2289
|
// having: nothing else about the node changes, and a cached layout
|
|
2279
|
-
// carries the face it was shaped with.
|
|
2280
|
-
|
|
2290
|
+
// carries the face it was shaped with. `_retext` runs on a mount too —
|
|
2291
|
+
// its own claims are bounded — but cannot answer non-zero there: a
|
|
2292
|
+
// node that was never attached has never resolved a text style.
|
|
2293
|
+
if (this._retext() !== 0 && !mounting) {
|
|
2294
|
+
this.root?.invalidate(true, null, 'theme');
|
|
2295
|
+
}
|
|
2281
2296
|
if (wasDirection !== undefined && this.direction !== wasDirection) {
|
|
2282
2297
|
this._directionMoved();
|
|
2283
2298
|
}
|
|
2284
|
-
for (const child of this.children) child._themeChanged();
|
|
2299
|
+
for (const child of this.children) child._themeChanged(mounting);
|
|
2285
2300
|
} finally {
|
|
2286
2301
|
inThemeWalk--;
|
|
2287
2302
|
}
|
|
@@ -2447,9 +2462,10 @@ export class Node {
|
|
|
2447
2462
|
// popups live anywhere in the JSX tree but are independent
|
|
2448
2463
|
// override-redirect windows: bookkeeping only, no yoga, no paint —
|
|
2449
2464
|
// but they do inherit the theme of where they are written
|
|
2465
|
+
const mounting = child.parent == null;
|
|
2450
2466
|
this._spliceChild(child, beforeChild);
|
|
2451
2467
|
child.parent = this;
|
|
2452
|
-
if (this.theme || child.props.theme) child._themeChanged();
|
|
2468
|
+
if (this.theme || child.props.theme) child._themeChanged(mounting);
|
|
2453
2469
|
a11yHooks.attached?.(this, child);
|
|
2454
2470
|
return;
|
|
2455
2471
|
}
|
|
@@ -2495,6 +2511,10 @@ export class Node {
|
|
|
2495
2511
|
if (child.parent === this && this._joinsYoga(child)) {
|
|
2496
2512
|
this.yoga.removeChild(child.yoga);
|
|
2497
2513
|
}
|
|
2514
|
+
// no parent means never attached: this insert is a mount, and the theme
|
|
2515
|
+
// walk resolves without claiming — a keyed reorder arrives here too, with
|
|
2516
|
+
// its parent still set, and that one keeps the claims (issue #402)
|
|
2517
|
+
const mounting = child.parent == null;
|
|
2498
2518
|
const index = this._spliceChild(child, beforeChild);
|
|
2499
2519
|
child.parent = this;
|
|
2500
2520
|
if (this._joinsYoga(child)) {
|
|
@@ -2504,7 +2524,7 @@ export class Node {
|
|
|
2504
2524
|
child._registerSizeQueries();
|
|
2505
2525
|
// it can see its ancestors now, so any token in its style can resolve.
|
|
2506
2526
|
// With no theme anywhere there is nothing to resolve and nothing to walk
|
|
2507
|
-
if (this.theme || child.props.theme) child._themeChanged();
|
|
2527
|
+
if (this.theme || child.props.theme) child._themeChanged(mounting);
|
|
2508
2528
|
this._textContentChanged();
|
|
2509
2529
|
this._childListChanged(before);
|
|
2510
2530
|
a11yHooks.attached?.(this, child);
|
|
@@ -3241,6 +3261,74 @@ export class Node {
|
|
|
3241
3261
|
}
|
|
3242
3262
|
}
|
|
3243
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
|
+
|
|
3244
3332
|
/**
|
|
3245
3333
|
* Ask the owning window to repaint. The damage lives on the window node,
|
|
3246
3334
|
* which is the only node with a frame clock — this forwards there, so an
|
|
@@ -3254,6 +3342,9 @@ export class Node {
|
|
|
3254
3342
|
* the mount invalidates in full anyway.
|
|
3255
3343
|
*/
|
|
3256
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();
|
|
3257
3348
|
this.root?.invalidate(layoutChanged, damage, reason);
|
|
3258
3349
|
}
|
|
3259
3350
|
|
|
@@ -3409,6 +3500,7 @@ export class Node {
|
|
|
3409
3500
|
* FULL_DAMAGE the way a bare `invalidate(true, null)` would.
|
|
3410
3501
|
*/
|
|
3411
3502
|
_invalidateLayout(reason) {
|
|
3503
|
+
this._markScrollMeasureDirty();
|
|
3412
3504
|
const root = this.root;
|
|
3413
3505
|
if (!root) return;
|
|
3414
3506
|
// Same walk, same frame, same answer — see `_childListBefore`, whose
|
|
@@ -5259,6 +5351,10 @@ export const Scrollable = (Base) =>
|
|
|
5259
5351
|
this.scrollX = 0;
|
|
5260
5352
|
this.contentHeight = 0;
|
|
5261
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;
|
|
5262
5358
|
}
|
|
5263
5359
|
|
|
5264
5360
|
/**
|
|
@@ -5281,6 +5377,9 @@ export const Scrollable = (Base) =>
|
|
|
5281
5377
|
* position the same way when a box stops scrolling.
|
|
5282
5378
|
*/
|
|
5283
5379
|
_overflowChanged() {
|
|
5380
|
+
// whichever way the style flipped, the next scrolling pass starts
|
|
5381
|
+
// from a fresh measurement
|
|
5382
|
+
this._scrollMeasureDirty = true;
|
|
5284
5383
|
if (this.isScroller()) return;
|
|
5285
5384
|
this._scrollIntoViewTarget = null;
|
|
5286
5385
|
this._childOrigin = null;
|
|
@@ -5315,20 +5414,39 @@ export const Scrollable = (Base) =>
|
|
|
5315
5414
|
return;
|
|
5316
5415
|
}
|
|
5317
5416
|
const rtl = this.direction === 'rtl';
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
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 else — set 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();
|
|
5329
5449
|
}
|
|
5330
|
-
this.contentWidth = size.width;
|
|
5331
|
-
this.contentHeight = size.height;
|
|
5332
5450
|
this._resolveScrollIntoView();
|
|
5333
5451
|
this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
|
|
5334
5452
|
this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
|
|
@@ -5353,6 +5471,23 @@ export const Scrollable = (Base) =>
|
|
|
5353
5471
|
const wasOrigin = this._childOrigin;
|
|
5354
5472
|
const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
|
|
5355
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
|
+
}
|
|
5356
5491
|
const outer = layoutDiffSink;
|
|
5357
5492
|
const outerShift = layoutDiffShift;
|
|
5358
5493
|
const ledger = shifted && this._blitLedgerOpen();
|
|
@@ -5396,6 +5531,22 @@ export const Scrollable = (Base) =>
|
|
|
5396
5531
|
}
|
|
5397
5532
|
}
|
|
5398
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
|
+
|
|
5399
5550
|
/**
|
|
5400
5551
|
* Tell the owner how big the viewport and the content turned out, when
|
|
5401
5552
|
* either changes. Layout happens on the frame clock, *after* the commit
|
|
@@ -5457,8 +5608,14 @@ export const Scrollable = (Base) =>
|
|
|
5457
5608
|
* wheel, the scrollbars, the scroll keys and the AT-SPI scroll pane all
|
|
5458
5609
|
* read the numbers this returns (docs/extending.md).
|
|
5459
5610
|
*
|
|
5460
|
-
* Called once per layout pass, from `absolutize`, so it may
|
|
5461
|
-
* 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.
|
|
5462
5619
|
*/
|
|
5463
5620
|
measureScrollContent() {
|
|
5464
5621
|
const rtl = this.direction === 'rtl';
|
|
@@ -9743,6 +9900,7 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
9743
9900
|
return;
|
|
9744
9901
|
}
|
|
9745
9902
|
if (child.isWindow) {
|
|
9903
|
+
const mounting = child.parent == null;
|
|
9746
9904
|
this._spliceChild(child, beforeChild);
|
|
9747
9905
|
child.parent = this;
|
|
9748
9906
|
// Initial children are realized when this window realizes; a child
|
|
@@ -9754,7 +9912,7 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
9754
9912
|
child.realize(this.window);
|
|
9755
9913
|
if (child.window) this._xStack.push(child.window.id);
|
|
9756
9914
|
}
|
|
9757
|
-
if (this.theme || child.props.theme) child._themeChanged();
|
|
9915
|
+
if (this.theme || child.props.theme) child._themeChanged(mounting);
|
|
9758
9916
|
// React reorders a keyed list with one insertBefore per moved child;
|
|
9759
9917
|
// restacking once at the end of the commit skips the intermediate
|
|
9760
9918
|
// orders, which nobody ever sees.
|