react-x11 2.1.0 → 2.1.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/nodes.js +435 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
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;
@@ -2199,8 +2244,19 @@ export class Node {
2199
2244
  }
2200
2245
 
2201
2246
  /** The theme above or on this node changed: drop the caches and restyle
2202
- * the subtree, since a token can appear at any depth. */
2203
- _themeChanged() {
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) {
2204
2260
  // This walk visits every node itself, so the per-node re-resolution below
2205
2261
  // is enough — a style swap it causes must not start a second walk of the
2206
2262
  // same subtree from halfway down.
@@ -2224,19 +2280,23 @@ export class Node {
2224
2280
  if (localTextStyleChanged(this.style, before)) {
2225
2281
  this._textContentChanged();
2226
2282
  }
2227
- this.root?.invalidate(true, null, 'theme');
2283
+ if (!mounting) this.root?.invalidate(true, null, 'theme');
2228
2284
  }
2229
2285
  // The palette is the floor under the cascade, so a theme swap moves the
2230
2286
  // resolved style of every node that named none of its own — and none of
2231
2287
  // that is in a style object, so nothing above would have noticed. A
2232
2288
  // swap that only changes `fontFamily` is the case that made this worth
2233
2289
  // having: nothing else about the node changes, and a cached layout
2234
- // carries the face it was shaped with.
2235
- if (this._retext() !== 0) this.root?.invalidate(true, null, 'theme');
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
+ }
2236
2296
  if (wasDirection !== undefined && this.direction !== wasDirection) {
2237
2297
  this._directionMoved();
2238
2298
  }
2239
- for (const child of this.children) child._themeChanged();
2299
+ for (const child of this.children) child._themeChanged(mounting);
2240
2300
  } finally {
2241
2301
  inThemeWalk--;
2242
2302
  }
@@ -2256,6 +2316,12 @@ export class Node {
2256
2316
  /** Number of yoga-bearing children before `index` (window children and
2257
2317
  * text spans/chunks do not join the parent's yoga tree). */
2258
2318
  _yogaIndexAt(index) {
2319
+ // A list of ordinary boxes — a scroll pane's rows, which is the list
2320
+ // this is asked about a hundred times in one commit — has every child in
2321
+ // the yoga tree, and then the yoga index *is* the child index. Counting
2322
+ // the exceptions as they arrive turns that answer into a read instead of
2323
+ // a walk of every sibling in front of the new row (issue #397).
2324
+ if (this._nonYogaKids === 0) return index;
2259
2325
  let n = 0;
2260
2326
  for (let i = 0; i < index; i++) {
2261
2327
  if (this._joinsYoga(this.children[i])) n++;
@@ -2267,6 +2333,24 @@ export class Node {
2267
2333
  return Boolean(this.yoga && child.yoga && !child.isWindow);
2268
2334
  }
2269
2335
 
2336
+ /**
2337
+ * Where `child` sits in `this.children`.
2338
+ *
2339
+ * The cached slot is checked rather than trusted: a node appears in the
2340
+ * list once, so `children[i] === child` *is* the proof that `i` is its
2341
+ * index, and a cache that has gone stale costs a scan rather than a wrong
2342
+ * answer. `_spliceChild` refreshes the two slots it knows — the child it
2343
+ * placed and the sibling it pushed along — which is what keeps a run of
2344
+ * inserts in front of the same trailing sibling (every virtualized list's
2345
+ * commit) off the scan entirely.
2346
+ */
2347
+ _indexOfChild(child) {
2348
+ if (this.children[child._childIndex] === child) return child._childIndex;
2349
+ const i = this.children.indexOf(child);
2350
+ child._childIndex = i;
2351
+ return i;
2352
+ }
2353
+
2270
2354
  /**
2271
2355
  * Give this node's box a measure function, keeping a reference that can be
2272
2356
  * asked again later.
@@ -2354,12 +2438,22 @@ export class Node {
2354
2438
  * by calling insertBefore with a child that is *already* mounted here, and
2355
2439
  * without the removal it would appear twice. Returns the new index. */
2356
2440
  _spliceChild(child, beforeChild) {
2357
- const from = this.children.indexOf(child);
2441
+ // `parent === this` is the cheap form of "already in this list" — the
2442
+ // two are set and cleared together — so a child arriving for the first
2443
+ // time, which is every node of a freshly mounted subtree, pays no scan
2444
+ // at all for the question.
2445
+ const from = child.parent === this ? this._indexOfChild(child) : -1;
2358
2446
  if (from !== -1) this.children.splice(from, 1);
2359
- const before =
2360
- beforeChild == null ? -1 : this.children.indexOf(beforeChild);
2447
+ else if (outsideYoga(child)) this._nonYogaKids++;
2448
+ const before = beforeChild == null ? -1 : this._indexOfChild(beforeChild);
2361
2449
  const index = before === -1 ? this.children.length : before;
2362
2450
  this.children.splice(index, 0, child);
2451
+ // The two slots this splice knows. Every other cached index at or after
2452
+ // `index` has shifted by one and will be caught by the check in
2453
+ // `_indexOfChild`; these two are the ones a run of inserts in front of
2454
+ // the same sibling asks about again on the very next call.
2455
+ child._childIndex = index;
2456
+ if (beforeChild != null) beforeChild._childIndex = index + 1;
2363
2457
  return index;
2364
2458
  }
2365
2459
 
@@ -2368,9 +2462,10 @@ export class Node {
2368
2462
  // popups live anywhere in the JSX tree but are independent
2369
2463
  // override-redirect windows: bookkeeping only, no yoga, no paint —
2370
2464
  // but they do inherit the theme of where they are written
2465
+ const mounting = child.parent == null;
2371
2466
  this._spliceChild(child, beforeChild);
2372
2467
  child.parent = this;
2373
- if (this.theme || child.props.theme) child._themeChanged();
2468
+ if (this.theme || child.props.theme) child._themeChanged(mounting);
2374
2469
  a11yHooks.attached?.(this, child);
2375
2470
  return;
2376
2471
  }
@@ -2407,13 +2502,19 @@ export class Node {
2407
2502
  );
2408
2503
  }
2409
2504
  // captured before the child joins, so it covers the arrangement that is
2410
- // about to be replaced (see _childListChanged)
2411
- const before = this.paintBounds();
2505
+ // about to be replaced (see _childListChanged). A viewport mid-blit has
2506
+ // nothing vacating — the child being added had no pixels — and the
2507
+ // layout diff claims where it lands, so it names no region at all.
2508
+ const before = this._blitLedgerOpen() ? null : this._childListBefore();
2412
2509
  // a move has to leave the yoga tree too — yoga aborts on insertChild of
2413
2510
  // a node that still has a parent
2414
- if (this.children.includes(child) && this._joinsYoga(child)) {
2511
+ if (child.parent === this && this._joinsYoga(child)) {
2415
2512
  this.yoga.removeChild(child.yoga);
2416
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;
2417
2518
  const index = this._spliceChild(child, beforeChild);
2418
2519
  child.parent = this;
2419
2520
  if (this._joinsYoga(child)) {
@@ -2423,12 +2524,98 @@ export class Node {
2423
2524
  child._registerSizeQueries();
2424
2525
  // it can see its ancestors now, so any token in its style can resolve.
2425
2526
  // With no theme anywhere there is nothing to resolve and nothing to walk
2426
- if (this.theme || child.props.theme) child._themeChanged();
2527
+ if (this.theme || child.props.theme) child._themeChanged(mounting);
2427
2528
  this._textContentChanged();
2428
2529
  this._childListChanged(before);
2429
2530
  a11yHooks.attached?.(this, child);
2430
2531
  }
2431
2532
 
2533
+ /**
2534
+ * Is this node a scroll container that has a blit armed and still clean
2535
+ * this frame (issue #398)?
2536
+ *
2537
+ * While it is, the window keeps a *ledger* of the regions that actually
2538
+ * changed inside the viewport instead of cancelling the blit at the first
2539
+ * sign of one. The coarse claims this node would otherwise make — its own
2540
+ * box, which is all `paintBounds()` can say for a node that clips — would
2541
+ * cover the whole band the blit is about to move and throw that ledger
2542
+ * away, so the paths that make them take a finer route while this is true.
2543
+ *
2544
+ * `scrollContents` is out: an element blit already tests foreign claims
2545
+ * against the rect it handed over (issue #309), and its region is not a
2546
+ * viewport whose children *are* the scrolled content.
2547
+ */
2548
+ _blitLedgerOpen() {
2549
+ const from = this._pendingBlitFrom;
2550
+ return (
2551
+ from != null &&
2552
+ from !== BLIT_POISONED &&
2553
+ !this._pendingBlitContents &&
2554
+ this._blitLedger != null
2555
+ );
2556
+ }
2557
+
2558
+ /**
2559
+ * Write one changed region into this viewport's ledger, in the coordinates
2560
+ * it was named in. Returns false when the frame is better off repainting
2561
+ * the viewport — too many regions to be worth the bookkeeping, or one big
2562
+ * enough that there is nothing left for the blit to keep — which the
2563
+ * caller turns into the poison the gate used to apply unconditionally.
2564
+ *
2565
+ * Which side of the frame's layout pass the rect came from decides
2566
+ * whether it moves with the blit: a claim made during the commit names
2567
+ * where the content sits *now*, and the blit is about to shift it, so
2568
+ * `_applyScrollBlits` shifts the rect too. A claim raised once layout has
2569
+ * run — the diff's, the reflow queue's — already names where it landed.
2570
+ * Read off the window rather than passed in, so a claim from application
2571
+ * code reached during the layout pass is filed on the right side of it.
2572
+ */
2573
+ _recordBlitClaim(rect) {
2574
+ const ledger = this._blitLedger;
2575
+ if (!ledger || ledger.length >= BLIT_MAX_CLAIMS) return false;
2576
+ const inside = intersectRects(rect, this.abs);
2577
+ // beside the band the blit moves: those pixels are painted the ordinary
2578
+ // way, out of the frame's own damage
2579
+ if (!inside) return true;
2580
+ // …and a claim that covers the viewport leaves the blit nothing to keep
2581
+ if (rectContains(inside, this.abs)) return false;
2582
+ ledger.push({ ...inside, pre: !this.root?._laidOut });
2583
+ return true;
2584
+ }
2585
+
2586
+ /**
2587
+ * This node's paint bounds from before a child-list mutation — the `before`
2588
+ * half of `_childListChanged`'s protocol, captured while a departing child
2589
+ * is still attached.
2590
+ *
2591
+ * Walked once per node per frame rather than once per mutation. A commit
2592
+ * that mounts a virtualized list's window inserts a hundred rows into one
2593
+ * pane, one `insertBefore` at a time, and a walk of the whole pane per row
2594
+ * is what made that commit O(rows x pane) (issue #397).
2595
+ *
2596
+ * Reusing the first walk's answer is not an approximation. Nothing is laid
2597
+ * out or painted between two mutations in the same frame, so every child
2598
+ * still carries the rect it was last painted at, and a child that leaves
2599
+ * later in the frame was in the list — and so inside the rect — when the
2600
+ * first walk ran. `root._reflowed` is the marker for "this frame already
2601
+ * has one", which is exactly its lifetime: joined at the first claim,
2602
+ * cleared by `flush()`.
2603
+ */
2604
+ _childListBefore() {
2605
+ const root = this.root;
2606
+ // A subtree still being built off-tree claims nothing — this is the
2607
+ // `appendInitialChild` path, which is most of a mount, and where the
2608
+ // walk used to be thrown away by `_childListChanged`'s `!root` return.
2609
+ if (!root) return null;
2610
+ if (root._reflowed.has(this) && this._reflowBefore) {
2611
+ return this._reflowBefore;
2612
+ }
2613
+ // NO_DAMAGE, not null, when a blitting viewport above clips this node
2614
+ // away entirely (issue #398): null here would read as "somewhere" and
2615
+ // repaint the window.
2616
+ return (this._reflowBefore = this._claimBounds() ?? NO_DAMAGE);
2617
+ }
2618
+
2432
2619
  /**
2433
2620
  * A child was inserted or removed. `before` is this node's paint bounds from
2434
2621
  * *before* the mutation, which the caller has to capture while the departing
@@ -2448,20 +2635,35 @@ export class Node {
2448
2635
  this._clearHitBounds();
2449
2636
  const root = this.root;
2450
2637
  if (!root) return;
2638
+ // A viewport keeping a ledger this frame (issue #398) says both halves
2639
+ // of the protocol finer: `before` is the departing child's own rect
2640
+ // rather than this node's box, and the "after" half comes from the
2641
+ // shifted layout diff, which claims an entering child where it lands
2642
+ // and says nothing about the ones that only rode the scroll. Joining
2643
+ // `_reflowed` would undo both — its post-layout claim is this node's
2644
+ // box, the whole band the blit is about to move.
2645
+ if (this._blitLedgerOpen()) {
2646
+ root.invalidate(true, before ?? NO_DAMAGE, 'child-list');
2647
+ return;
2648
+ }
2451
2649
  root.invalidate(true, before, 'child-list');
2452
2650
  root._reflowed.add(this);
2453
2651
  }
2454
2652
 
2455
2653
  removeChild(child) {
2456
- const index = this.children.indexOf(child);
2654
+ const index = this._indexOfChild(child);
2457
2655
  if (index === -1) return;
2458
2656
  // told while the child is still wired, so the bridge can compute the
2459
2657
  // index the AT will see the removal at
2460
2658
  a11yHooks.detach?.(this, child);
2461
2659
  // 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();
2660
+ // child is about to stop occupying — the child's own, for a viewport
2661
+ // mid-blit, where this node's box is the whole scrolled band
2662
+ const before = this._blitLedgerOpen()
2663
+ ? (child._claimBounds() ?? NO_DAMAGE)
2664
+ : this._childListBefore();
2464
2665
  this.children.splice(index, 1);
2666
+ if (outsideYoga(child)) this._nonYogaKids--;
2465
2667
  if (this._joinsYoga(child)) {
2466
2668
  this.yoga.removeChild(child.yoga);
2467
2669
  }
@@ -3020,7 +3222,37 @@ export class Node {
3020
3222
  this._clearHitBounds();
3021
3223
  if (layoutDiffSink) {
3022
3224
  const grow = this._outlineExtent() + DAMAGE_SLOP;
3023
- if (old.width > 0 && old.height > 0) {
3225
+ const shift = layoutDiffShift;
3226
+ const had = old.width > 0 && old.height > 0;
3227
+ if (shift) {
3228
+ // Riding a blit (issue #398): the rect this node *would* have had if
3229
+ // nothing but the scroll had happened. Landing there is the blit's
3230
+ // own translation and claims nothing — claiming it would repaint the
3231
+ // band the blit exists to keep. Landing anywhere else is a real move,
3232
+ // and both ends of it are claimed in post-blit coordinates, which is
3233
+ // where the frame will paint them.
3234
+ const was = {
3235
+ x: old.x + shift.x,
3236
+ y: old.y + shift.y,
3237
+ width: old.width,
3238
+ height: old.height,
3239
+ };
3240
+ if (
3241
+ had &&
3242
+ was.x === x &&
3243
+ was.y === y &&
3244
+ old.width === width &&
3245
+ old.height === height
3246
+ ) {
3247
+ return;
3248
+ }
3249
+ if (had) layoutDiffSink(insetRect(was, -grow));
3250
+ if (width > 0 && height > 0) {
3251
+ layoutDiffSink(insetRect(this.abs, -grow));
3252
+ }
3253
+ return;
3254
+ }
3255
+ if (had) {
3024
3256
  layoutDiffSink(insetRect(old, -grow));
3025
3257
  }
3026
3258
  if (width > 0 && height > 0) {
@@ -3199,10 +3431,48 @@ export class Node {
3199
3431
  _invalidateLayout(reason) {
3200
3432
  const root = this.root;
3201
3433
  if (!root) return;
3202
- root.invalidate(true, this.paintBounds(), reason);
3434
+ // Same walk, same frame, same answer — see `_childListBefore`, whose
3435
+ // record this shares so that a reflow and a child-list change on one
3436
+ // node in one frame walk the subtree once between them.
3437
+ root.invalidate(true, this._childListBefore(), reason);
3203
3438
  root._reflowed.add(this);
3204
3439
  }
3205
3440
 
3441
+ /**
3442
+ * `paintBounds()` with the one clip a damage claim must respect: a scroll
3443
+ * container above this node that is waiting to blit (issue #398).
3444
+ *
3445
+ * A viewport clips its children, so the part of a claim outside its box is
3446
+ * pixels that cannot appear — and leaving it in costs the blit the frame.
3447
+ * A virtualized list is the shape that makes this concrete: its spacers
3448
+ * are boxes thousands of pixels tall whose visible extent is a sliver or
3449
+ * nothing at all, and their unclipped claims, coalesced into the scroll's
3450
+ * own, leave `_blitKeptDamage` a damage rect many times the viewport to
3451
+ * refuse. Null when the clip left nothing.
3452
+ *
3453
+ * Only while a blit is pending — outside that this is `paintBounds()` and
3454
+ * one property read. Clipping every claim to every clipping ancestor
3455
+ * would be correct too, and is a bigger change than the frame this is
3456
+ * about.
3457
+ */
3458
+ _claimBounds() {
3459
+ const bounds = this.paintBounds();
3460
+ const sv = this._blitViewport();
3461
+ return sv ? intersectRects(bounds, sv.paintBounds()) : bounds;
3462
+ }
3463
+
3464
+ /** The scroll container above this node that is waiting to blit, if there
3465
+ * is one — the viewport whose ledger this node's claims belong in, and
3466
+ * whose box clips them (issue #398). One property read when no blit is
3467
+ * pending, which is every frame that is not a scroll. */
3468
+ _blitViewport() {
3469
+ if (!this.root?._pendingScrolls?.size) return null;
3470
+ for (let n = this.parent; n; n = n.parent) {
3471
+ if (n._blitLedgerOpen()) return n;
3472
+ }
3473
+ return null;
3474
+ }
3475
+
3206
3476
  /**
3207
3477
  * The region this node can put ink in: its own rect unioned with every
3208
3478
  * descendant's. Not the same as `abs` — a child of a node that does not
@@ -5100,13 +5370,31 @@ export const Scrollable = (Base) =>
5100
5370
  // real layout — claim it, but clipped to the viewport: ink below the
5101
5371
  // fold never reaches the surface, and an unclipped claim would repaint
5102
5372
  // 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);
5373
+ const wasOrigin = this._childOrigin;
5374
+ const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
5106
5375
  this._childOrigin = { x: ox, y: oy };
5107
5376
  const outer = layoutDiffSink;
5377
+ const outerShift = layoutDiffShift;
5378
+ const ledger = shifted && this._blitLedgerOpen();
5108
5379
  if (outer) {
5109
- if (shifted) {
5380
+ if (ledger) {
5381
+ // The blit's own ledger takes this walk (issue #398). The shift
5382
+ // below is what makes the diff worth running under a scroll at
5383
+ // all: without it every child reports the move the blit is about
5384
+ // to make for them, and the claims add up to the viewport. What
5385
+ // is left is the virtualized list's real frame — the rows that
5386
+ // entered, the ones that left, a spacer that resized — and it
5387
+ // goes to the ledger rather than to `outer`, whose claims are
5388
+ // what `layoutMoved` reads as "this frame is not a pure scroll".
5389
+ const vp = insetRect(this.abs, -DAMAGE_SLOP);
5390
+ layoutDiffSink = (rect) => {
5391
+ const clipped = intersectRects(rect, vp);
5392
+ if (clipped && !this._recordBlitClaim(clipped)) {
5393
+ this._pendingBlitFrom = BLIT_POISONED;
5394
+ }
5395
+ };
5396
+ layoutDiffShift = { x: ox - wasOrigin.x, y: oy - wasOrigin.y };
5397
+ } else if (shifted) {
5110
5398
  layoutDiffSink = null;
5111
5399
  } else {
5112
5400
  const vp = insetRect(this.abs, -DAMAGE_SLOP);
@@ -5124,6 +5412,7 @@ export const Scrollable = (Base) =>
5124
5412
  }
5125
5413
  } finally {
5126
5414
  layoutDiffSink = outer;
5415
+ layoutDiffShift = outerShift;
5127
5416
  }
5128
5417
  }
5129
5418
 
@@ -5274,10 +5563,19 @@ export const Scrollable = (Base) =>
5274
5563
  // has not claimed yet, so damage already overlapping this viewport
5275
5564
  // is foreign by construction: poison the frame instead of arming,
5276
5565
  // and the full-viewport repaint below stays in force.
5277
- if (this._pendingBlitFrom == null && Array.isArray(root._damage)) {
5566
+ const arming = this._pendingBlitFrom == null;
5567
+ // The ledger this frame's changes inside the viewport are written
5568
+ // to (issue #398). Opened with the blit and read by
5569
+ // _applyScrollBlits, which clears it beside the origin.
5570
+ if (arming) this._blitLedger = [];
5571
+ if (arming && Array.isArray(root._damage)) {
5278
5572
  const zone = insetRect(this.abs, -(DAMAGE_SLOP * 2 + 1));
5279
5573
  for (const rect of root._damage) {
5280
- if (rectsOverlap(rect, zone)) {
5574
+ // Already coalesced, so these rects are as coarse as the frame
5575
+ // has made them — which the ledger reads conservatively: a blob
5576
+ // that swallowed the viewport says so and poisons, exactly as
5577
+ // this gate used to for every claim it saw.
5578
+ if (rectsOverlap(rect, zone) && !this._recordBlitClaim(rect)) {
5281
5579
  this._pendingBlitFrom = BLIT_POISONED;
5282
5580
  break;
5283
5581
  }
@@ -9465,6 +9763,7 @@ export class WindowNode extends Scrollable(Node) {
9465
9763
  return;
9466
9764
  }
9467
9765
  if (child.isWindow) {
9766
+ const mounting = child.parent == null;
9468
9767
  this._spliceChild(child, beforeChild);
9469
9768
  child.parent = this;
9470
9769
  // Initial children are realized when this window realizes; a child
@@ -9476,7 +9775,7 @@ export class WindowNode extends Scrollable(Node) {
9476
9775
  child.realize(this.window);
9477
9776
  if (child.window) this._xStack.push(child.window.id);
9478
9777
  }
9479
- if (this.theme || child.props.theme) child._themeChanged();
9778
+ if (this.theme || child.props.theme) child._themeChanged(mounting);
9480
9779
  // React reorders a keyed list with one insertBefore per moved child;
9481
9780
  // restacking once at the end of the commit skips the intermediate
9482
9781
  // orders, which nobody ever sees.
@@ -9488,8 +9787,11 @@ export class WindowNode extends Scrollable(Node) {
9488
9787
 
9489
9788
  removeChild(child) {
9490
9789
  if (child.isWindow) {
9491
- const index = this.children.indexOf(child);
9492
- if (index !== -1) this.children.splice(index, 1);
9790
+ const index = this._indexOfChild(child);
9791
+ if (index !== -1) {
9792
+ this.children.splice(index, 1);
9793
+ this._nonYogaKids--;
9794
+ }
9493
9795
  const id = child.window?.id;
9494
9796
  child.parent = null;
9495
9797
  child.destroySubtree();
@@ -9951,14 +10253,21 @@ export class WindowNode extends Scrollable(Node) {
9951
10253
  // rects coalesce a change inside the viewport is indistinguishable from
9952
10254
  // the scroll's own claim. (Unbounded claims need no check: FULL_DAMAGE
9953
10255
  // fails the blit's damage gate by itself.)
10256
+ // The region this claim actually covers — a node's paint reach, clipped
10257
+ // to a blitting viewport above it (issue #398), or the bare rect a
10258
+ // caller handed over. Null when the clip left nothing (the node draws
10259
+ // where nothing can be seen, so it owes no pixels), and null on a frame
10260
+ // that is already unbounded, which owes neither a rect nor the subtree
10261
+ // walk that measures one — a blit cannot fire there either.
10262
+ const bounds =
10263
+ damage && damage !== NO_DAMAGE && this._damage !== FULL_DAMAGE
10264
+ ? damage._claimBounds
10265
+ ? damage._claimBounds()
10266
+ : damage
10267
+ : null;
9954
10268
  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;
10269
+ if (pendingScrolls?.size && bounds && this._scrollClaim !== damage) {
10270
+ const rect = bounds;
9962
10271
  for (const sv of pendingScrolls) {
9963
10272
  // An element blitting a region of its own drawing (issue #303) is
9964
10273
  // waiting on that region, not on the whole node it lives in — and
@@ -9979,6 +10288,15 @@ export class WindowNode extends Scrollable(Node) {
9979
10288
  ? contents.rect
9980
10289
  : sv.abs && insetRect(sv.abs, -(DAMAGE_SLOP * 2 + 1));
9981
10290
  if (!waiting || rectsOverlap(rect, waiting)) {
10291
+ // …unless this viewport is keeping a ledger of what changed
10292
+ // inside it (issue #398): the region goes in the ledger and
10293
+ // `_applyScrollBlits` repaints it after the blit, which is the
10294
+ // same pixels on screen for a fraction of the drawing. The
10295
+ // ledger says no when the frame stops paying, and then this
10296
+ // falls through to the poison exactly as before.
10297
+ if (sv._blitLedgerOpen() && sv._recordBlitClaim(rect)) {
10298
+ continue;
10299
+ }
9982
10300
  // Poison rather than disarm (react-x11#295): a null here would
9983
10301
  // let a second scrollTo in the same frame re-arm from a
9984
10302
  // mid-frame origin, and the blit would then move pixels that
@@ -9992,16 +10310,18 @@ export class WindowNode extends Scrollable(Node) {
9992
10310
  }
9993
10311
  if (layoutChanged && !damage) this._damage = FULL_DAMAGE;
9994
10312
  else if (!layoutChanged && !damage) this._damage = FULL_DAMAGE;
9995
- else if (this._damage !== FULL_DAMAGE) {
10313
+ else if (!bounds) {
10314
+ // A layout change that names no region: either NO_DAMAGE, from a
10315
+ // caller with a finer claim already in flight, or a node whose reach
10316
+ // a clipping ancestor left nothing of (issue #398). Unlike `!damage`
10317
+ // neither is "something, somewhere", so neither costs a full repaint.
10318
+ } else if (this._damage !== FULL_DAMAGE) {
9996
10319
  // a node, or a bare rect for a caller that has a region rather than a
9997
10320
  // node — a subtree that is about to be removed, say. Claims accumulate
9998
10321
  // as a list of rects rather than one box around them all, so two changes
9999
10322
  // at opposite corners of the window no longer repaint everything
10000
10323
  // between them.
10001
- this._damage = addDamageRect(
10002
- this._damage,
10003
- damage.paintBounds ? damage.paintBounds() : damage,
10004
- );
10324
+ this._damage = addDamageRect(this._damage, bounds);
10005
10325
  }
10006
10326
  this.needsPaint = true;
10007
10327
  // Recorded before the `_scheduled` gate, not inside it: the debt is
@@ -10056,6 +10376,10 @@ export class WindowNode extends Scrollable(Node) {
10056
10376
  // not the flag's post-pass value
10057
10377
  const layoutRan = this.needsLayout;
10058
10378
  if (this.needsLayout) {
10379
+ // From here on a claim names where its content *landed*, not where it
10380
+ // sat before the scroll — which is what decides whether a blit
10381
+ // ledger's rect moves with the shift (issue #398).
10382
+ this._laidOut = true;
10059
10383
  this._resolveSizeQueries(width, height);
10060
10384
  this._applyContentFloors(width);
10061
10385
  this.yoga.setWidth(width);
@@ -10091,14 +10415,25 @@ export class WindowNode extends Scrollable(Node) {
10091
10415
  // replaced it. Claimed after layout because an inserted child has no
10092
10416
  // rect before it.
10093
10417
  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());
10418
+ // …and the pre-mutation walk this frame reused goes with it
10419
+ node._reflowBefore = null;
10420
+ if (node.destroyed || this._damage === FULL_DAMAGE) continue;
10421
+ // clipped to a blitting viewport above it, like every other claim
10422
+ // this frame, and written to that viewport's ledger too (issue
10423
+ // #398): the claim would otherwise coalesce into the scroll's own
10424
+ // and be dropped with it, leaving the band the blit kept holding
10425
+ // this node's pixels from before the reflow.
10426
+ const after = node._claimBounds();
10427
+ if (!after) continue;
10428
+ const sv = node._blitViewport();
10429
+ if (sv && !sv._recordBlitClaim(after)) {
10430
+ sv._pendingBlitFrom = BLIT_POISONED;
10431
+ }
10432
+ this._damage = addDamageRect(this._damage, after);
10099
10433
  }
10100
10434
  this._reflowed.clear();
10101
10435
  } else if (this._reflowed.size) {
10436
+ for (const node of this._reflowed) node._reflowBefore = null;
10102
10437
  this._reflowed.clear();
10103
10438
  }
10104
10439
  // any node this pass laid out may be what an open popup is anchored to
@@ -10107,6 +10442,9 @@ export class WindowNode extends Scrollable(Node) {
10107
10442
  // a frame that turns out to be a pure scroll blits the surviving band
10108
10443
  // and narrows its claim to the exposed strip
10109
10444
  this._applyScrollBlits(width, height, layoutMoved);
10445
+ // …and the next commit's claims name the arrangement this frame leaves
10446
+ // behind again, from before whatever scroll comes with them
10447
+ this._laidOut = false;
10110
10448
  if (!this.needsPaint) return;
10111
10449
  this.needsPaint = false;
10112
10450
  const damage = this._takeDamage(width, height);
@@ -10185,9 +10523,11 @@ export class WindowNode extends Scrollable(Node) {
10185
10523
  pending.clear();
10186
10524
  const from = nodes[0]._pendingBlitFrom;
10187
10525
  const contents = nodes[0]._pendingBlitContents;
10526
+ const ledger = nodes[0]._blitLedger;
10188
10527
  for (const n of nodes) {
10189
10528
  n._pendingBlitFrom = null;
10190
10529
  n._pendingBlitContents = null;
10530
+ n._blitLedger = null;
10191
10531
  }
10192
10532
  // two viewports scrolling in one frame is rare enough that sorting out
10193
10533
  // whether their regions interact is not worth it
@@ -10267,11 +10607,39 @@ export class WindowNode extends Scrollable(Node) {
10267
10607
  }
10268
10608
  const keep = this._blitKeptDamage(vp);
10269
10609
  if (!keep) return;
10610
+ // What changed inside the viewport while the blit was armed, in the
10611
+ // coordinates the frame is about to paint in (issue #398). A claim made
10612
+ // during the commit named where the content sat before the shift, and
10613
+ // the blit is about to move those pixels by the frame's delta, so it
10614
+ // moves with them; a claim from the layout diff already landed there.
10615
+ //
10616
+ // Repainting the result is what makes the blit honest about them: the
10617
+ // blit translates the previous frame's rendering, which is correct
10618
+ // everywhere the content did not change, and these are the places it
10619
+ // did. That is finer than the strip-only rule issue #398 asks for and
10620
+ // no more complicated, so a mid-viewport change — a row upgrading from
10621
+ // skeleton to content while the list scrolls — rides the fast path too
10622
+ // instead of falling back to the whole viewport.
10623
+ const repairs = [];
10624
+ let repairArea = 0;
10625
+ for (const claim of ledger ?? []) {
10626
+ const moved = claim.pre
10627
+ ? {
10628
+ x: claim.x - dx,
10629
+ y: claim.y - dy,
10630
+ width: claim.width,
10631
+ height: claim.height,
10632
+ }
10633
+ : claim;
10634
+ const inside = intersectRects(moved, vp);
10635
+ if (!inside) continue;
10636
+ repairs.push(inside);
10637
+ repairArea += inside.width * inside.height;
10638
+ }
10639
+ // past this the blit plus a scatter of repaints is no longer cheaper
10640
+ // than the one full-viewport pass it replaced
10641
+ if (repairArea > area * BLIT_MAX_CLAIM_AREA) return;
10270
10642
  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
10643
  let rects = keep;
10276
10644
  // the strip the shift exposed, full breadth — it also covers the corner
10277
10645
  // gutter beside the bars, whose old pixels the blit did not overwrite
@@ -10326,6 +10694,24 @@ export class WindowNode extends Scrollable(Node) {
10326
10694
  }
10327
10695
  const crossBar = node._scrollbar(axis === 'y' ? 'x' : 'y');
10328
10696
  if (crossBar) rects = addDamageRect(rects, scrollbarTrackRect(crossBar));
10697
+ for (const repair of repairs) rects = addDamageRect(rects, repair);
10698
+ // The last gate, and the only one that has to wait until the rects are
10699
+ // assembled: the frame carries at most MAX_DAMAGE_RECTS of them, so a
10700
+ // repair that does not sit beside the strip is merged with whatever is
10701
+ // nearest — the scrollbar column, most often — and the box of that
10702
+ // merge can reach back across the viewport. When it does, the blit is
10703
+ // buying a shift and paying for the viewport anyway, so let the plain
10704
+ // repaint scrollTo already claimed have the frame.
10705
+ let painted = 0;
10706
+ for (const rect of rects) {
10707
+ const inside = intersectRects(rect, vp);
10708
+ if (inside) painted += inside.width * inside.height;
10709
+ }
10710
+ if (painted > area * SCROLL_BLIT_MAX_REPAINT) return;
10711
+ // scroll offsets grow down/right; the pixels move the other way
10712
+ // (0 - x rather than -x: negating +0 yields -0, which survives into
10713
+ // request buffers and test comparisons)
10714
+ if (!wnd.scrollRegion({ ...vp }, 0 - dx, 0 - dy)) return;
10329
10715
  this._damage = rects;
10330
10716
  }
10331
10717