react-x11 2.0.1 → 2.1.1

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 (3) hide show
  1. package/package.json +4 -3
  2. package/src/nodes.js +405 -40
  3. package/src/scale.js +136 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -49,6 +49,7 @@
49
49
  "examples:foreign": "tsx examples/foreign.jsx",
50
50
  "examples:frame": "tsx examples/frame.jsx",
51
51
  "labs:text-baseline": "tsx examples/labs/text-baseline.jsx",
52
+ "labs:direct-gl": "tsx examples/labs/direct-gl.jsx",
52
53
  "examples:viewer3d": "tsx examples/viewer3d.jsx",
53
54
  "examples:transparent": "tsx examples/transparent.jsx",
54
55
  "examples:windows": "tsx examples/windows.jsx",
@@ -82,7 +83,7 @@
82
83
  "node": ">=20.19"
83
84
  },
84
85
  "dependencies": {
85
- "ntk": "^8.3.1",
86
+ "ntk": "^8.4.0",
86
87
  "react-reconciler": "^0.33.0",
87
88
  "yoga-layout": "^3.2.1"
88
89
  },
@@ -90,10 +91,10 @@
90
91
  "dbus-native": "^0.15.1"
91
92
  },
92
93
  "peerDependencies": {
93
- "react": "^19.0.0",
94
94
  "@babel/core": "^8.0.0",
95
95
  "@babel/plugin-transform-react-jsx": "^8.0.0",
96
96
  "hot-module-replacement": "^4.0.1",
97
+ "react": "^19.0.0",
97
98
  "react-refresh": "^0.18.0"
98
99
  },
99
100
  "peerDependenciesMeta": {
package/src/nodes.js CHANGED
@@ -254,6 +254,15 @@ const DAMAGE_SLOP = 1;
254
254
  // pass, and always restored through `finally`.
255
255
  let layoutDiffSink = null;
256
256
 
257
+ // The uniform translation the subtree currently being walked is riding: set
258
+ // while a scroll container whose blit is armed lays its children out, so the
259
+ // diff can tell "moved" from "scrolled" (issue #398). Every child of such a
260
+ // container lands at its old rect plus this shift, which is precisely what
261
+ // the blit is about to do to those pixels — so it is not a change, and the
262
+ // diff reports only the children that landed somewhere else. Null everywhere
263
+ // else, and restored through `finally` like the sink beside it.
264
+ let layoutDiffShift = null;
265
+
257
266
  // What an invalidate() may name as its reason — a small closed set, so the
258
267
  // frame log, the tracer and the full-repaint warning can print "why" next
259
268
  // to "where". A typo'd reason would silently vanish from every report, so
@@ -314,6 +323,13 @@ const NO_SCROLL_BLIT = process.env.REACT_X11_NO_SCROLL_BLIT === '1';
314
323
  // exposed strip is most of a repaint anyway.
315
324
  const SCROLL_BLIT_MIN_KEEP = 0.5;
316
325
 
326
+ // …and how much of it the frame may end up repainting anyway. The strip and
327
+ // the scrollbar repair sit under this by a wide margin; what can push past
328
+ // it is a ledger repair (issue #398) that the damage cap had to merge with
329
+ // the scrollbar column, whose box then reaches back across the viewport.
330
+ // Past this the blit is buying a shift and paying for the viewport anyway.
331
+ const SCROLL_BLIT_MAX_REPAINT = 0.75;
332
+
317
333
  // The server-side event mask every realized window ends up with. The
318
334
  // subscriptions are a constant — the EventManager's pointer/key/focus
319
335
  // listeners, the window's own resize/draw/expose pair, the backing store's
@@ -353,6 +369,14 @@ const BLIT_POISONED = Object.freeze({ poisoned: true });
353
369
  // net delta in `_pendingBlitContents` beside it.
354
370
  const BLIT_CONTENTS = Object.freeze({ contents: true });
355
371
 
372
+ // How much of what changed inside a blitting viewport the ledger will carry
373
+ // before the frame gives up and repaints the viewport instead (issue #398).
374
+ // A virtualized list's scroll frame changes a handful of regions — the two
375
+ // spacers and the entering rows — and past that the blit plus a scatter of
376
+ // repaints stops being cheaper than the one pass it replaced.
377
+ const BLIT_MAX_CLAIMS = 8;
378
+ const BLIT_MAX_CLAIM_AREA = 0.25;
379
+
356
380
  const rectContains = (outer, inner) =>
357
381
  outer.x <= inner.x &&
358
382
  outer.y <= inner.y &&
@@ -1441,6 +1465,13 @@ function shallowEqual(a, b) {
1441
1465
  return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
1442
1466
  }
1443
1467
 
1468
+ /** The half of `Node._joinsYoga` that is about the child alone — a real X
1469
+ * window (`<window>`, `<popup>`) or a node built without a box at all (a text
1470
+ * chunk) sits outside whatever parent it lands in. This is what
1471
+ * `_nonYogaKids` counts, so the count stays right for a parent that has no
1472
+ * box of its own either. */
1473
+ const outsideYoga = (child) => !child.yoga || child.isWindow;
1474
+
1444
1475
  export class Node {
1445
1476
  get ownerDocument() {
1446
1477
  return DEVTOOLS_FAKE_DOCUMENT;
@@ -1465,6 +1496,20 @@ export class Node {
1465
1496
  this.app = app;
1466
1497
  this.parent = null;
1467
1498
  this.children = [];
1499
+ // Where this node sits in `parent.children`, and how many of *this*
1500
+ // node's children sit outside its yoga tree. Both are bookkeeping that
1501
+ // turns the scans `insertBefore` used to do over the whole child list
1502
+ // into constant work, which is what stops a commit that mounts a
1503
+ // virtualized list's window from costing O(rows x pane) (issue #397).
1504
+ // The index is a hint — `_indexOfChild` proves it before using it — and
1505
+ // the count is exact, maintained by the three places `children` is
1506
+ // spliced.
1507
+ this._childIndex = -1;
1508
+ this._nonYogaKids = 0;
1509
+ // The pre-mutation bounds this frame already claimed for this node, so
1510
+ // that a second mutation reuses the rect instead of walking the subtree
1511
+ // again. Lives exactly as long as membership in `root._reflowed`.
1512
+ this._reflowBefore = null;
1468
1513
  this.root = null; // owning WindowNode once attached
1469
1514
  this.hidden = false;
1470
1515
  this.destroyed = false;
@@ -2256,6 +2301,12 @@ export class Node {
2256
2301
  /** Number of yoga-bearing children before `index` (window children and
2257
2302
  * text spans/chunks do not join the parent's yoga tree). */
2258
2303
  _yogaIndexAt(index) {
2304
+ // A list of ordinary boxes — a scroll pane's rows, which is the list
2305
+ // this is asked about a hundred times in one commit — has every child in
2306
+ // the yoga tree, and then the yoga index *is* the child index. Counting
2307
+ // the exceptions as they arrive turns that answer into a read instead of
2308
+ // a walk of every sibling in front of the new row (issue #397).
2309
+ if (this._nonYogaKids === 0) return index;
2259
2310
  let n = 0;
2260
2311
  for (let i = 0; i < index; i++) {
2261
2312
  if (this._joinsYoga(this.children[i])) n++;
@@ -2267,6 +2318,24 @@ export class Node {
2267
2318
  return Boolean(this.yoga && child.yoga && !child.isWindow);
2268
2319
  }
2269
2320
 
2321
+ /**
2322
+ * Where `child` sits in `this.children`.
2323
+ *
2324
+ * The cached slot is checked rather than trusted: a node appears in the
2325
+ * list once, so `children[i] === child` *is* the proof that `i` is its
2326
+ * index, and a cache that has gone stale costs a scan rather than a wrong
2327
+ * answer. `_spliceChild` refreshes the two slots it knows — the child it
2328
+ * placed and the sibling it pushed along — which is what keeps a run of
2329
+ * inserts in front of the same trailing sibling (every virtualized list's
2330
+ * commit) off the scan entirely.
2331
+ */
2332
+ _indexOfChild(child) {
2333
+ if (this.children[child._childIndex] === child) return child._childIndex;
2334
+ const i = this.children.indexOf(child);
2335
+ child._childIndex = i;
2336
+ return i;
2337
+ }
2338
+
2270
2339
  /**
2271
2340
  * Give this node's box a measure function, keeping a reference that can be
2272
2341
  * asked again later.
@@ -2354,12 +2423,22 @@ export class Node {
2354
2423
  * by calling insertBefore with a child that is *already* mounted here, and
2355
2424
  * without the removal it would appear twice. Returns the new index. */
2356
2425
  _spliceChild(child, beforeChild) {
2357
- const from = this.children.indexOf(child);
2426
+ // `parent === this` is the cheap form of "already in this list" — the
2427
+ // two are set and cleared together — so a child arriving for the first
2428
+ // time, which is every node of a freshly mounted subtree, pays no scan
2429
+ // at all for the question.
2430
+ const from = child.parent === this ? this._indexOfChild(child) : -1;
2358
2431
  if (from !== -1) this.children.splice(from, 1);
2359
- const before =
2360
- beforeChild == null ? -1 : this.children.indexOf(beforeChild);
2432
+ else if (outsideYoga(child)) this._nonYogaKids++;
2433
+ const before = beforeChild == null ? -1 : this._indexOfChild(beforeChild);
2361
2434
  const index = before === -1 ? this.children.length : before;
2362
2435
  this.children.splice(index, 0, child);
2436
+ // The two slots this splice knows. Every other cached index at or after
2437
+ // `index` has shifted by one and will be caught by the check in
2438
+ // `_indexOfChild`; these two are the ones a run of inserts in front of
2439
+ // the same sibling asks about again on the very next call.
2440
+ child._childIndex = index;
2441
+ if (beforeChild != null) beforeChild._childIndex = index + 1;
2363
2442
  return index;
2364
2443
  }
2365
2444
 
@@ -2407,11 +2486,13 @@ export class Node {
2407
2486
  );
2408
2487
  }
2409
2488
  // captured before the child joins, so it covers the arrangement that is
2410
- // about to be replaced (see _childListChanged)
2411
- const before = this.paintBounds();
2489
+ // about to be replaced (see _childListChanged). A viewport mid-blit has
2490
+ // nothing vacating — the child being added had no pixels — and the
2491
+ // layout diff claims where it lands, so it names no region at all.
2492
+ const before = this._blitLedgerOpen() ? null : this._childListBefore();
2412
2493
  // a move has to leave the yoga tree too — yoga aborts on insertChild of
2413
2494
  // a node that still has a parent
2414
- if (this.children.includes(child) && this._joinsYoga(child)) {
2495
+ if (child.parent === this && this._joinsYoga(child)) {
2415
2496
  this.yoga.removeChild(child.yoga);
2416
2497
  }
2417
2498
  const index = this._spliceChild(child, beforeChild);
@@ -2429,6 +2510,92 @@ export class Node {
2429
2510
  a11yHooks.attached?.(this, child);
2430
2511
  }
2431
2512
 
2513
+ /**
2514
+ * Is this node a scroll container that has a blit armed and still clean
2515
+ * this frame (issue #398)?
2516
+ *
2517
+ * While it is, the window keeps a *ledger* of the regions that actually
2518
+ * changed inside the viewport instead of cancelling the blit at the first
2519
+ * sign of one. The coarse claims this node would otherwise make — its own
2520
+ * box, which is all `paintBounds()` can say for a node that clips — would
2521
+ * cover the whole band the blit is about to move and throw that ledger
2522
+ * away, so the paths that make them take a finer route while this is true.
2523
+ *
2524
+ * `scrollContents` is out: an element blit already tests foreign claims
2525
+ * against the rect it handed over (issue #309), and its region is not a
2526
+ * viewport whose children *are* the scrolled content.
2527
+ */
2528
+ _blitLedgerOpen() {
2529
+ const from = this._pendingBlitFrom;
2530
+ return (
2531
+ from != null &&
2532
+ from !== BLIT_POISONED &&
2533
+ !this._pendingBlitContents &&
2534
+ this._blitLedger != null
2535
+ );
2536
+ }
2537
+
2538
+ /**
2539
+ * Write one changed region into this viewport's ledger, in the coordinates
2540
+ * it was named in. Returns false when the frame is better off repainting
2541
+ * the viewport — too many regions to be worth the bookkeeping, or one big
2542
+ * enough that there is nothing left for the blit to keep — which the
2543
+ * caller turns into the poison the gate used to apply unconditionally.
2544
+ *
2545
+ * Which side of the frame's layout pass the rect came from decides
2546
+ * whether it moves with the blit: a claim made during the commit names
2547
+ * where the content sits *now*, and the blit is about to shift it, so
2548
+ * `_applyScrollBlits` shifts the rect too. A claim raised once layout has
2549
+ * run — the diff's, the reflow queue's — already names where it landed.
2550
+ * Read off the window rather than passed in, so a claim from application
2551
+ * code reached during the layout pass is filed on the right side of it.
2552
+ */
2553
+ _recordBlitClaim(rect) {
2554
+ const ledger = this._blitLedger;
2555
+ if (!ledger || ledger.length >= BLIT_MAX_CLAIMS) return false;
2556
+ const inside = intersectRects(rect, this.abs);
2557
+ // beside the band the blit moves: those pixels are painted the ordinary
2558
+ // way, out of the frame's own damage
2559
+ if (!inside) return true;
2560
+ // …and a claim that covers the viewport leaves the blit nothing to keep
2561
+ if (rectContains(inside, this.abs)) return false;
2562
+ ledger.push({ ...inside, pre: !this.root?._laidOut });
2563
+ return true;
2564
+ }
2565
+
2566
+ /**
2567
+ * This node's paint bounds from before a child-list mutation — the `before`
2568
+ * half of `_childListChanged`'s protocol, captured while a departing child
2569
+ * is still attached.
2570
+ *
2571
+ * Walked once per node per frame rather than once per mutation. A commit
2572
+ * that mounts a virtualized list's window inserts a hundred rows into one
2573
+ * pane, one `insertBefore` at a time, and a walk of the whole pane per row
2574
+ * is what made that commit O(rows x pane) (issue #397).
2575
+ *
2576
+ * Reusing the first walk's answer is not an approximation. Nothing is laid
2577
+ * out or painted between two mutations in the same frame, so every child
2578
+ * still carries the rect it was last painted at, and a child that leaves
2579
+ * later in the frame was in the list — and so inside the rect — when the
2580
+ * first walk ran. `root._reflowed` is the marker for "this frame already
2581
+ * has one", which is exactly its lifetime: joined at the first claim,
2582
+ * cleared by `flush()`.
2583
+ */
2584
+ _childListBefore() {
2585
+ const root = this.root;
2586
+ // A subtree still being built off-tree claims nothing — this is the
2587
+ // `appendInitialChild` path, which is most of a mount, and where the
2588
+ // walk used to be thrown away by `_childListChanged`'s `!root` return.
2589
+ if (!root) return null;
2590
+ if (root._reflowed.has(this) && this._reflowBefore) {
2591
+ return this._reflowBefore;
2592
+ }
2593
+ // NO_DAMAGE, not null, when a blitting viewport above clips this node
2594
+ // away entirely (issue #398): null here would read as "somewhere" and
2595
+ // repaint the window.
2596
+ return (this._reflowBefore = this._claimBounds() ?? NO_DAMAGE);
2597
+ }
2598
+
2432
2599
  /**
2433
2600
  * A child was inserted or removed. `before` is this node's paint bounds from
2434
2601
  * *before* the mutation, which the caller has to capture while the departing
@@ -2448,20 +2615,35 @@ export class Node {
2448
2615
  this._clearHitBounds();
2449
2616
  const root = this.root;
2450
2617
  if (!root) return;
2618
+ // A viewport keeping a ledger this frame (issue #398) says both halves
2619
+ // of the protocol finer: `before` is the departing child's own rect
2620
+ // rather than this node's box, and the "after" half comes from the
2621
+ // shifted layout diff, which claims an entering child where it lands
2622
+ // and says nothing about the ones that only rode the scroll. Joining
2623
+ // `_reflowed` would undo both — its post-layout claim is this node's
2624
+ // box, the whole band the blit is about to move.
2625
+ if (this._blitLedgerOpen()) {
2626
+ root.invalidate(true, before ?? NO_DAMAGE, 'child-list');
2627
+ return;
2628
+ }
2451
2629
  root.invalidate(true, before, 'child-list');
2452
2630
  root._reflowed.add(this);
2453
2631
  }
2454
2632
 
2455
2633
  removeChild(child) {
2456
- const index = this.children.indexOf(child);
2634
+ const index = this._indexOfChild(child);
2457
2635
  if (index === -1) return;
2458
2636
  // told while the child is still wired, so the bridge can compute the
2459
2637
  // index the AT will see the removal at
2460
2638
  a11yHooks.detach?.(this, child);
2461
2639
  // captured while the child is still attached, so it covers the rect the
2462
- // child is about to stop occupying
2463
- const before = this.paintBounds();
2640
+ // child is about to stop occupying — the child's own, for a viewport
2641
+ // mid-blit, where this node's box is the whole scrolled band
2642
+ const before = this._blitLedgerOpen()
2643
+ ? (child._claimBounds() ?? NO_DAMAGE)
2644
+ : this._childListBefore();
2464
2645
  this.children.splice(index, 1);
2646
+ if (outsideYoga(child)) this._nonYogaKids--;
2465
2647
  if (this._joinsYoga(child)) {
2466
2648
  this.yoga.removeChild(child.yoga);
2467
2649
  }
@@ -3020,7 +3202,37 @@ export class Node {
3020
3202
  this._clearHitBounds();
3021
3203
  if (layoutDiffSink) {
3022
3204
  const grow = this._outlineExtent() + DAMAGE_SLOP;
3023
- if (old.width > 0 && old.height > 0) {
3205
+ const shift = layoutDiffShift;
3206
+ const had = old.width > 0 && old.height > 0;
3207
+ if (shift) {
3208
+ // Riding a blit (issue #398): the rect this node *would* have had if
3209
+ // nothing but the scroll had happened. Landing there is the blit's
3210
+ // own translation and claims nothing — claiming it would repaint the
3211
+ // band the blit exists to keep. Landing anywhere else is a real move,
3212
+ // and both ends of it are claimed in post-blit coordinates, which is
3213
+ // where the frame will paint them.
3214
+ const was = {
3215
+ x: old.x + shift.x,
3216
+ y: old.y + shift.y,
3217
+ width: old.width,
3218
+ height: old.height,
3219
+ };
3220
+ if (
3221
+ had &&
3222
+ was.x === x &&
3223
+ was.y === y &&
3224
+ old.width === width &&
3225
+ old.height === height
3226
+ ) {
3227
+ return;
3228
+ }
3229
+ if (had) layoutDiffSink(insetRect(was, -grow));
3230
+ if (width > 0 && height > 0) {
3231
+ layoutDiffSink(insetRect(this.abs, -grow));
3232
+ }
3233
+ return;
3234
+ }
3235
+ if (had) {
3024
3236
  layoutDiffSink(insetRect(old, -grow));
3025
3237
  }
3026
3238
  if (width > 0 && height > 0) {
@@ -3199,10 +3411,48 @@ export class Node {
3199
3411
  _invalidateLayout(reason) {
3200
3412
  const root = this.root;
3201
3413
  if (!root) return;
3202
- root.invalidate(true, this.paintBounds(), reason);
3414
+ // Same walk, same frame, same answer — see `_childListBefore`, whose
3415
+ // record this shares so that a reflow and a child-list change on one
3416
+ // node in one frame walk the subtree once between them.
3417
+ root.invalidate(true, this._childListBefore(), reason);
3203
3418
  root._reflowed.add(this);
3204
3419
  }
3205
3420
 
3421
+ /**
3422
+ * `paintBounds()` with the one clip a damage claim must respect: a scroll
3423
+ * container above this node that is waiting to blit (issue #398).
3424
+ *
3425
+ * A viewport clips its children, so the part of a claim outside its box is
3426
+ * pixels that cannot appear — and leaving it in costs the blit the frame.
3427
+ * A virtualized list is the shape that makes this concrete: its spacers
3428
+ * are boxes thousands of pixels tall whose visible extent is a sliver or
3429
+ * nothing at all, and their unclipped claims, coalesced into the scroll's
3430
+ * own, leave `_blitKeptDamage` a damage rect many times the viewport to
3431
+ * refuse. Null when the clip left nothing.
3432
+ *
3433
+ * Only while a blit is pending — outside that this is `paintBounds()` and
3434
+ * one property read. Clipping every claim to every clipping ancestor
3435
+ * would be correct too, and is a bigger change than the frame this is
3436
+ * about.
3437
+ */
3438
+ _claimBounds() {
3439
+ const bounds = this.paintBounds();
3440
+ const sv = this._blitViewport();
3441
+ return sv ? intersectRects(bounds, sv.paintBounds()) : bounds;
3442
+ }
3443
+
3444
+ /** The scroll container above this node that is waiting to blit, if there
3445
+ * is one — the viewport whose ledger this node's claims belong in, and
3446
+ * whose box clips them (issue #398). One property read when no blit is
3447
+ * pending, which is every frame that is not a scroll. */
3448
+ _blitViewport() {
3449
+ if (!this.root?._pendingScrolls?.size) return null;
3450
+ for (let n = this.parent; n; n = n.parent) {
3451
+ if (n._blitLedgerOpen()) return n;
3452
+ }
3453
+ return null;
3454
+ }
3455
+
3206
3456
  /**
3207
3457
  * The region this node can put ink in: its own rect unioned with every
3208
3458
  * descendant's. Not the same as `abs` — a child of a node that does not
@@ -5100,13 +5350,31 @@ export const Scrollable = (Base) =>
5100
5350
  // real layout — claim it, but clipped to the viewport: ink below the
5101
5351
  // fold never reaches the surface, and an unclipped claim would repaint
5102
5352
  // whatever unrelated UI sits under this node's off-viewport extent.
5103
- const shifted =
5104
- this._childOrigin &&
5105
- (this._childOrigin.x !== ox || this._childOrigin.y !== oy);
5353
+ const wasOrigin = this._childOrigin;
5354
+ const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
5106
5355
  this._childOrigin = { x: ox, y: oy };
5107
5356
  const outer = layoutDiffSink;
5357
+ const outerShift = layoutDiffShift;
5358
+ const ledger = shifted && this._blitLedgerOpen();
5108
5359
  if (outer) {
5109
- if (shifted) {
5360
+ if (ledger) {
5361
+ // The blit's own ledger takes this walk (issue #398). The shift
5362
+ // below is what makes the diff worth running under a scroll at
5363
+ // all: without it every child reports the move the blit is about
5364
+ // to make for them, and the claims add up to the viewport. What
5365
+ // is left is the virtualized list's real frame — the rows that
5366
+ // entered, the ones that left, a spacer that resized — and it
5367
+ // goes to the ledger rather than to `outer`, whose claims are
5368
+ // what `layoutMoved` reads as "this frame is not a pure scroll".
5369
+ const vp = insetRect(this.abs, -DAMAGE_SLOP);
5370
+ layoutDiffSink = (rect) => {
5371
+ const clipped = intersectRects(rect, vp);
5372
+ if (clipped && !this._recordBlitClaim(clipped)) {
5373
+ this._pendingBlitFrom = BLIT_POISONED;
5374
+ }
5375
+ };
5376
+ layoutDiffShift = { x: ox - wasOrigin.x, y: oy - wasOrigin.y };
5377
+ } else if (shifted) {
5110
5378
  layoutDiffSink = null;
5111
5379
  } else {
5112
5380
  const vp = insetRect(this.abs, -DAMAGE_SLOP);
@@ -5124,6 +5392,7 @@ export const Scrollable = (Base) =>
5124
5392
  }
5125
5393
  } finally {
5126
5394
  layoutDiffSink = outer;
5395
+ layoutDiffShift = outerShift;
5127
5396
  }
5128
5397
  }
5129
5398
 
@@ -5274,10 +5543,19 @@ export const Scrollable = (Base) =>
5274
5543
  // has not claimed yet, so damage already overlapping this viewport
5275
5544
  // is foreign by construction: poison the frame instead of arming,
5276
5545
  // and the full-viewport repaint below stays in force.
5277
- if (this._pendingBlitFrom == null && Array.isArray(root._damage)) {
5546
+ const arming = this._pendingBlitFrom == null;
5547
+ // The ledger this frame's changes inside the viewport are written
5548
+ // to (issue #398). Opened with the blit and read by
5549
+ // _applyScrollBlits, which clears it beside the origin.
5550
+ if (arming) this._blitLedger = [];
5551
+ if (arming && Array.isArray(root._damage)) {
5278
5552
  const zone = insetRect(this.abs, -(DAMAGE_SLOP * 2 + 1));
5279
5553
  for (const rect of root._damage) {
5280
- if (rectsOverlap(rect, zone)) {
5554
+ // Already coalesced, so these rects are as coarse as the frame
5555
+ // has made them — which the ledger reads conservatively: a blob
5556
+ // that swallowed the viewport says so and poisons, exactly as
5557
+ // this gate used to for every claim it saw.
5558
+ if (rectsOverlap(rect, zone) && !this._recordBlitClaim(rect)) {
5281
5559
  this._pendingBlitFrom = BLIT_POISONED;
5282
5560
  break;
5283
5561
  }
@@ -9488,8 +9766,11 @@ export class WindowNode extends Scrollable(Node) {
9488
9766
 
9489
9767
  removeChild(child) {
9490
9768
  if (child.isWindow) {
9491
- const index = this.children.indexOf(child);
9492
- if (index !== -1) this.children.splice(index, 1);
9769
+ const index = this._indexOfChild(child);
9770
+ if (index !== -1) {
9771
+ this.children.splice(index, 1);
9772
+ this._nonYogaKids--;
9773
+ }
9493
9774
  const id = child.window?.id;
9494
9775
  child.parent = null;
9495
9776
  child.destroySubtree();
@@ -9951,14 +10232,21 @@ export class WindowNode extends Scrollable(Node) {
9951
10232
  // rects coalesce a change inside the viewport is indistinguishable from
9952
10233
  // the scroll's own claim. (Unbounded claims need no check: FULL_DAMAGE
9953
10234
  // fails the blit's damage gate by itself.)
10235
+ // The region this claim actually covers — a node's paint reach, clipped
10236
+ // to a blitting viewport above it (issue #398), or the bare rect a
10237
+ // caller handed over. Null when the clip left nothing (the node draws
10238
+ // where nothing can be seen, so it owes no pixels), and null on a frame
10239
+ // that is already unbounded, which owes neither a rect nor the subtree
10240
+ // walk that measures one — a blit cannot fire there either.
10241
+ const bounds =
10242
+ damage && damage !== NO_DAMAGE && this._damage !== FULL_DAMAGE
10243
+ ? damage._claimBounds
10244
+ ? damage._claimBounds()
10245
+ : damage
10246
+ : null;
9954
10247
  const pendingScrolls = this._pendingScrolls;
9955
- if (
9956
- pendingScrolls?.size &&
9957
- damage &&
9958
- damage !== NO_DAMAGE &&
9959
- this._scrollClaim !== damage
9960
- ) {
9961
- const rect = damage.paintBounds ? damage.paintBounds() : damage;
10248
+ if (pendingScrolls?.size && bounds && this._scrollClaim !== damage) {
10249
+ const rect = bounds;
9962
10250
  for (const sv of pendingScrolls) {
9963
10251
  // An element blitting a region of its own drawing (issue #303) is
9964
10252
  // waiting on that region, not on the whole node it lives in — and
@@ -9979,6 +10267,15 @@ export class WindowNode extends Scrollable(Node) {
9979
10267
  ? contents.rect
9980
10268
  : sv.abs && insetRect(sv.abs, -(DAMAGE_SLOP * 2 + 1));
9981
10269
  if (!waiting || rectsOverlap(rect, waiting)) {
10270
+ // …unless this viewport is keeping a ledger of what changed
10271
+ // inside it (issue #398): the region goes in the ledger and
10272
+ // `_applyScrollBlits` repaints it after the blit, which is the
10273
+ // same pixels on screen for a fraction of the drawing. The
10274
+ // ledger says no when the frame stops paying, and then this
10275
+ // falls through to the poison exactly as before.
10276
+ if (sv._blitLedgerOpen() && sv._recordBlitClaim(rect)) {
10277
+ continue;
10278
+ }
9982
10279
  // Poison rather than disarm (react-x11#295): a null here would
9983
10280
  // let a second scrollTo in the same frame re-arm from a
9984
10281
  // mid-frame origin, and the blit would then move pixels that
@@ -9992,16 +10289,18 @@ export class WindowNode extends Scrollable(Node) {
9992
10289
  }
9993
10290
  if (layoutChanged && !damage) this._damage = FULL_DAMAGE;
9994
10291
  else if (!layoutChanged && !damage) this._damage = FULL_DAMAGE;
9995
- else if (this._damage !== FULL_DAMAGE) {
10292
+ else if (!bounds) {
10293
+ // A layout change that names no region: either NO_DAMAGE, from a
10294
+ // caller with a finer claim already in flight, or a node whose reach
10295
+ // a clipping ancestor left nothing of (issue #398). Unlike `!damage`
10296
+ // neither is "something, somewhere", so neither costs a full repaint.
10297
+ } else if (this._damage !== FULL_DAMAGE) {
9996
10298
  // a node, or a bare rect for a caller that has a region rather than a
9997
10299
  // node — a subtree that is about to be removed, say. Claims accumulate
9998
10300
  // as a list of rects rather than one box around them all, so two changes
9999
10301
  // at opposite corners of the window no longer repaint everything
10000
10302
  // between them.
10001
- this._damage = addDamageRect(
10002
- this._damage,
10003
- damage.paintBounds ? damage.paintBounds() : damage,
10004
- );
10303
+ this._damage = addDamageRect(this._damage, bounds);
10005
10304
  }
10006
10305
  this.needsPaint = true;
10007
10306
  // Recorded before the `_scheduled` gate, not inside it: the debt is
@@ -10056,6 +10355,10 @@ export class WindowNode extends Scrollable(Node) {
10056
10355
  // not the flag's post-pass value
10057
10356
  const layoutRan = this.needsLayout;
10058
10357
  if (this.needsLayout) {
10358
+ // From here on a claim names where its content *landed*, not where it
10359
+ // sat before the scroll — which is what decides whether a blit
10360
+ // ledger's rect moves with the shift (issue #398).
10361
+ this._laidOut = true;
10059
10362
  this._resolveSizeQueries(width, height);
10060
10363
  this._applyContentFloors(width);
10061
10364
  this.yoga.setWidth(width);
@@ -10091,14 +10394,25 @@ export class WindowNode extends Scrollable(Node) {
10091
10394
  // replaced it. Claimed after layout because an inserted child has no
10092
10395
  // rect before it.
10093
10396
  for (const node of this._reflowed) {
10094
- if (!node.destroyed)
10095
- this._damage =
10096
- this._damage === FULL_DAMAGE
10097
- ? FULL_DAMAGE
10098
- : addDamageRect(this._damage, node.paintBounds());
10397
+ // …and the pre-mutation walk this frame reused goes with it
10398
+ node._reflowBefore = null;
10399
+ if (node.destroyed || this._damage === FULL_DAMAGE) continue;
10400
+ // clipped to a blitting viewport above it, like every other claim
10401
+ // this frame, and written to that viewport's ledger too (issue
10402
+ // #398): the claim would otherwise coalesce into the scroll's own
10403
+ // and be dropped with it, leaving the band the blit kept holding
10404
+ // this node's pixels from before the reflow.
10405
+ const after = node._claimBounds();
10406
+ if (!after) continue;
10407
+ const sv = node._blitViewport();
10408
+ if (sv && !sv._recordBlitClaim(after)) {
10409
+ sv._pendingBlitFrom = BLIT_POISONED;
10410
+ }
10411
+ this._damage = addDamageRect(this._damage, after);
10099
10412
  }
10100
10413
  this._reflowed.clear();
10101
10414
  } else if (this._reflowed.size) {
10415
+ for (const node of this._reflowed) node._reflowBefore = null;
10102
10416
  this._reflowed.clear();
10103
10417
  }
10104
10418
  // any node this pass laid out may be what an open popup is anchored to
@@ -10107,6 +10421,9 @@ export class WindowNode extends Scrollable(Node) {
10107
10421
  // a frame that turns out to be a pure scroll blits the surviving band
10108
10422
  // and narrows its claim to the exposed strip
10109
10423
  this._applyScrollBlits(width, height, layoutMoved);
10424
+ // …and the next commit's claims name the arrangement this frame leaves
10425
+ // behind again, from before whatever scroll comes with them
10426
+ this._laidOut = false;
10110
10427
  if (!this.needsPaint) return;
10111
10428
  this.needsPaint = false;
10112
10429
  const damage = this._takeDamage(width, height);
@@ -10185,9 +10502,11 @@ export class WindowNode extends Scrollable(Node) {
10185
10502
  pending.clear();
10186
10503
  const from = nodes[0]._pendingBlitFrom;
10187
10504
  const contents = nodes[0]._pendingBlitContents;
10505
+ const ledger = nodes[0]._blitLedger;
10188
10506
  for (const n of nodes) {
10189
10507
  n._pendingBlitFrom = null;
10190
10508
  n._pendingBlitContents = null;
10509
+ n._blitLedger = null;
10191
10510
  }
10192
10511
  // two viewports scrolling in one frame is rare enough that sorting out
10193
10512
  // whether their regions interact is not worth it
@@ -10267,11 +10586,39 @@ export class WindowNode extends Scrollable(Node) {
10267
10586
  }
10268
10587
  const keep = this._blitKeptDamage(vp);
10269
10588
  if (!keep) return;
10589
+ // What changed inside the viewport while the blit was armed, in the
10590
+ // coordinates the frame is about to paint in (issue #398). A claim made
10591
+ // during the commit named where the content sat before the shift, and
10592
+ // the blit is about to move those pixels by the frame's delta, so it
10593
+ // moves with them; a claim from the layout diff already landed there.
10594
+ //
10595
+ // Repainting the result is what makes the blit honest about them: the
10596
+ // blit translates the previous frame's rendering, which is correct
10597
+ // everywhere the content did not change, and these are the places it
10598
+ // did. That is finer than the strip-only rule issue #398 asks for and
10599
+ // no more complicated, so a mid-viewport change — a row upgrading from
10600
+ // skeleton to content while the list scrolls — rides the fast path too
10601
+ // instead of falling back to the whole viewport.
10602
+ const repairs = [];
10603
+ let repairArea = 0;
10604
+ for (const claim of ledger ?? []) {
10605
+ const moved = claim.pre
10606
+ ? {
10607
+ x: claim.x - dx,
10608
+ y: claim.y - dy,
10609
+ width: claim.width,
10610
+ height: claim.height,
10611
+ }
10612
+ : claim;
10613
+ const inside = intersectRects(moved, vp);
10614
+ if (!inside) continue;
10615
+ repairs.push(inside);
10616
+ repairArea += inside.width * inside.height;
10617
+ }
10618
+ // past this the blit plus a scatter of repaints is no longer cheaper
10619
+ // than the one full-viewport pass it replaced
10620
+ if (repairArea > area * BLIT_MAX_CLAIM_AREA) return;
10270
10621
  if (!this._scrollBlitSafe(node, vp)) return;
10271
- // scroll offsets grow down/right; the pixels move the other way
10272
- // (0 - x rather than -x: negating +0 yields -0, which survives into
10273
- // request buffers and test comparisons)
10274
- if (!wnd.scrollRegion({ ...vp }, 0 - dx, 0 - dy)) return;
10275
10622
  let rects = keep;
10276
10623
  // the strip the shift exposed, full breadth — it also covers the corner
10277
10624
  // gutter beside the bars, whose old pixels the blit did not overwrite
@@ -10326,6 +10673,24 @@ export class WindowNode extends Scrollable(Node) {
10326
10673
  }
10327
10674
  const crossBar = node._scrollbar(axis === 'y' ? 'x' : 'y');
10328
10675
  if (crossBar) rects = addDamageRect(rects, scrollbarTrackRect(crossBar));
10676
+ for (const repair of repairs) rects = addDamageRect(rects, repair);
10677
+ // The last gate, and the only one that has to wait until the rects are
10678
+ // assembled: the frame carries at most MAX_DAMAGE_RECTS of them, so a
10679
+ // repair that does not sit beside the strip is merged with whatever is
10680
+ // nearest — the scrollbar column, most often — and the box of that
10681
+ // merge can reach back across the viewport. When it does, the blit is
10682
+ // buying a shift and paying for the viewport anyway, so let the plain
10683
+ // repaint scrollTo already claimed have the frame.
10684
+ let painted = 0;
10685
+ for (const rect of rects) {
10686
+ const inside = intersectRects(rect, vp);
10687
+ if (inside) painted += inside.width * inside.height;
10688
+ }
10689
+ if (painted > area * SCROLL_BLIT_MAX_REPAINT) return;
10690
+ // scroll offsets grow down/right; the pixels move the other way
10691
+ // (0 - x rather than -x: negating +0 yields -0, which survives into
10692
+ // request buffers and test comparisons)
10693
+ if (!wnd.scrollRegion({ ...vp }, 0 - dx, 0 - dy)) return;
10329
10694
  this._damage = rects;
10330
10695
  }
10331
10696
 
package/src/scale.js CHANGED
@@ -54,6 +54,23 @@
54
54
  // subtly wrong size everywhere.
55
55
  // 6. **1**, the answer X11 shipped with in 1987.
56
56
  //
57
+ // Rungs 4 and 5 read hardware, so they are skipped where the connection is
58
+ // not describing hardware, and both cases are servers people really run:
59
+ //
60
+ // * **XQuartz** composes in macOS *points* and hands X the point space —
61
+ // the retina lid arrives as 1728x1080, already density-normalised, and
62
+ // the window server scales it onto the panel afterwards. Inferring a
63
+ // factor here doubles a size macOS is about to double again. Detected
64
+ // by the `Apple-WM` extension, exempted exactly like `XWAYLAND` in
65
+ // `VIRTUAL_OUTPUT_NAME`: the compositor owns scaling, so we do not.
66
+ // * **A single synthetic output** covering every monitor. XQuartz, Xvfb,
67
+ // VNC servers and Xephyr answer the RandR walk with one output named
68
+ // `default` whose CRTC is the *union* of the desktop; two 2560x1440
69
+ // monitors union to 5120x1440, which rung 5 would read as a retina
70
+ // panel. Xinerama reports those heads separately, so more heads than
71
+ // outputs retires rung 5 — see `isUnionOutput`. Rung 4 survives it:
72
+ // millimetres describe real glass however the pixels were split.
73
+ //
57
74
  // A machine can defeat every rung above the last two — the one this was
58
75
  // written against does: UTM in retina mode hands the guest the MacBook's
59
76
  // full 3456x2168 grid, QEMU's EDID invents millimetres that read as 100dpi,
@@ -269,13 +286,48 @@ export function snapScale(value) {
269
286
  return Math.min(3, Math.max(1, Math.round(value * 4) / 4));
270
287
  }
271
288
 
289
+ /**
290
+ * Is this RandR output list describing panels, or one synthetic output
291
+ * covering the whole desktop?
292
+ *
293
+ * Servers that never grew a real output model — XQuartz, Xvfb, x11vnc and
294
+ * TigerVNC, Xephyr, old drivers under `xorg.conf` — answer the RandR walk
295
+ * with a single output (usually named `default`, always without
296
+ * millimetres) whose CRTC is the *union* of every monitor attached. On a
297
+ * desktop with two monitors side by side that union is twice as wide as any
298
+ * panel in it, and rung 5 reads pixel counts: a pair of 2560x1440 monitors
299
+ * unions to 5120x1440 and is judged retina-class, which is how a 1x desk
300
+ * ends up drawing everything at double size.
301
+ *
302
+ * Xinerama is the cross-check, and it is free of the same blind spot: the
303
+ * same servers report the heads individually there, because that is the
304
+ * only geometry protocol they implement. More heads than outputs means the
305
+ * output list is not per-panel data, whatever it claims.
306
+ */
307
+ export function isUnionOutput(outputs, heads) {
308
+ return (
309
+ Array.isArray(outputs) &&
310
+ outputs.length > 0 &&
311
+ Number.isInteger(heads) &&
312
+ outputs.length < heads
313
+ );
314
+ }
315
+
272
316
  /**
273
317
  * One monitor's metadata → `{ scale, source, reason }`, using only what the
274
318
  * connection reported: pixel geometry, claimed millimetres, EDID. This is
275
319
  * rungs 4 and 5 of the ladder for one output; the caller stacks the
276
320
  * desktop-configuration rungs above it.
321
+ *
322
+ * `perPanel: false` says this record's geometry spans more than one monitor
323
+ * (`isUnionOutput` above), which retires rung 5 alone: the resolution class
324
+ * is a statement about *a panel* and means nothing about a union of them.
325
+ * Rung 4 still answers when it can, because credible millimetres describe
326
+ * real glass however the pixels were divided up afterwards — a `--setmonitor`
327
+ * split of one ultrawide is the case that reaches this branch on a server
328
+ * whose RandR is otherwise perfectly honest.
277
329
  */
278
- export function monitorScaleFromMetadata(monitor) {
330
+ export function monitorScaleFromMetadata(monitor, { perPanel = true } = {}) {
279
331
  const mm = classifyMm(monitor, monitor);
280
332
  const pxW = monitor.width ?? 0;
281
333
  const pxH = monitor.height ?? 0;
@@ -306,7 +358,7 @@ export function monitorScaleFromMetadata(monitor) {
306
358
  // grids stay at 1 on purpose — 2560x1440 is the commonest *1x* desk
307
359
  // monitor there is, and only millimetres could tell it from a 13" retina
308
360
  // lid, which is exactly the data this branch does not have.
309
- if (Math.min(pxW, pxH) >= 1800 || Math.max(pxW, pxH) >= 3000) {
361
+ if (perPanel && (Math.min(pxW, pxH) >= 1800 || Math.max(pxW, pxH) >= 3000)) {
310
362
  return {
311
363
  scale: 2,
312
364
  source: 'resolution',
@@ -316,7 +368,10 @@ export function monitorScaleFromMetadata(monitor) {
316
368
  return {
317
369
  scale: 1,
318
370
  source: 'default',
319
- reason: `no credible density data (mm ${mm}, ${pxW}x${pxH})`,
371
+ reason: perPanel
372
+ ? `no credible density data (mm ${mm}, ${pxW}x${pxH})`
373
+ : `${pxW}x${pxH} spans several monitors, so its pixel count says ` +
374
+ `nothing about any panel (mm ${mm})`,
320
375
  };
321
376
  }
322
377
 
@@ -468,6 +523,57 @@ async function readOutputs(app) {
468
523
  return connected.length ? connected : null;
469
524
  }
470
525
 
526
+ /**
527
+ * How many monitors Xinerama says are attached, or null where the server
528
+ * does not answer. Read only to sanity-check the RandR walk against
529
+ * (`isUnionOutput`) — the geometry itself is `screens.js`'s business.
530
+ *
531
+ * A server with the extension present but inactive replies with one screen
532
+ * covering everything, which is the same answer as "one monitor" and is
533
+ * treated as such: the cross-check needs a *disagreement* to fire.
534
+ */
535
+ async function countHeads(app) {
536
+ const xinerama = await requireExtension(app, 'xinerama');
537
+ if (!xinerama?.QueryScreens) return null;
538
+ const screens = await call(xinerama.QueryScreens.bind(xinerama));
539
+ return Array.isArray(screens) && screens.length ? screens.length : null;
540
+ }
541
+
542
+ /**
543
+ * Is this XQuartz?
544
+ *
545
+ * It matters because XQuartz is not handing us a framebuffer: macOS composes
546
+ * the desktop in *points* and hands X the point space, already normalised
547
+ * for density. A 16" retina lid arrives as 1728x1080 and a 1x desk monitor
548
+ * beside it as 2560x1403 — the same 14pt text is the same physical size on
549
+ * both, and the window server scales what it is given onto whichever panel
550
+ * the window lands on. Every hardware rung below is therefore answering a
551
+ * question that was already answered: inferring 2x from the pixels would
552
+ * double a size macOS is about to double again.
553
+ *
554
+ * This is the `XWAYLAND` exemption in `VIRTUAL_OUTPUT_NAME` for the same
555
+ * reason and by the same rule — the compositor owns scaling, so we do not —
556
+ * and it sits below the configured rungs, not above them, because a person
557
+ * who typed `REACT_X11_SCALE` or `GDK_SCALE` outranks the platform.
558
+ *
559
+ * `Apple-WM` is the extension quartz-wm drives; it is present on every
560
+ * XQuartz server and on no other X server, including when the client is
561
+ * remote and only the display is a Mac (which is exactly when the point
562
+ * space is still the truth).
563
+ */
564
+ function isQuartzServer(X) {
565
+ if (typeof X?.QueryExtension !== 'function') return Promise.resolve(false);
566
+ return new Promise((resolve) => {
567
+ try {
568
+ X.QueryExtension('Apple-WM', (err, reply) =>
569
+ resolve(!err && !!reply?.present),
570
+ );
571
+ } catch {
572
+ resolve(false);
573
+ }
574
+ });
575
+ }
576
+
471
577
  // --------------------------------------------------------------------------
472
578
  // The session
473
579
  // --------------------------------------------------------------------------
@@ -555,11 +661,35 @@ export async function beginScale(app, option) {
555
661
  return session;
556
662
  }
557
663
 
558
- // Rungs 4-5: the hardware, one verdict per output.
559
- const outputs = await readOutputs(app);
664
+ // Rungs 4-5: the hardware, one verdict per output — but only where the
665
+ // connection is describing hardware. The three reads go out together
666
+ // because none of them depends on another and this is the chain the first
667
+ // window waits behind.
668
+ const [quartz, outputs, heads] = await Promise.all([
669
+ isQuartzServer(X),
670
+ readOutputs(app),
671
+ countHeads(app),
672
+ ]);
673
+
674
+ if (quartz) {
675
+ // 1, and not because nothing answered: macOS already did the scaling.
676
+ session.source = 'xquartz';
677
+ trace(
678
+ '1x from Apple-WM — XQuartz hands X macOS point space, already scaled',
679
+ );
680
+ return session;
681
+ }
682
+
683
+ const perPanel = !isUnionOutput(outputs, heads);
560
684
  if (outputs) {
685
+ if (!perPanel) {
686
+ trace(
687
+ `${outputs.length} RandR output(s) against ${heads} Xinerama heads: ` +
688
+ 'the output list is not per-panel, so the resolution class is out',
689
+ );
690
+ }
561
691
  for (const monitor of outputs) {
562
- const verdict = monitorScaleFromMetadata(monitor);
692
+ const verdict = monitorScaleFromMetadata(monitor, { perPanel });
563
693
  session.monitors.set(monitor.name, {
564
694
  scale: verdict.scale,
565
695
  source: verdict.source,