react-x11 2.22.3 → 2.22.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.22.3",
3
+ "version": "2.22.5",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -491,6 +491,7 @@ export class NodeAnimation {
491
491
  this.root.needsLayout = true;
492
492
  this.root._floorsDirty = true;
493
493
  this.root._floorsSources.add(this);
494
+ this.root._floorsOtherSources.add(this);
494
495
  }
495
496
  }
496
497
  // A tick writes `this.style` without going through `_retarget`, so it
@@ -59,6 +59,13 @@ export const layoutDiff = {
59
59
  // would have claimed its children — claims its own box instead, since the
60
60
  // move claimed nothing for it.
61
61
  ride: false,
62
+
63
+ // Whether this walk may leave alone a subtree yoga did not reach under a
64
+ // parent that did not move (`Node._followParent`) — everything in it is
65
+ // where the last walk put it. False on a frame something scrolled on, or
66
+ // asked to: a scroll moves boxes without a pass, and the walk is what
67
+ // carries the offset down to them (`WindowNode._walkWhole`).
68
+ skipUnreached: false,
62
69
  };
63
70
 
64
71
  // What an invalidate() may name as its reason — a small closed set, so the
@@ -309,6 +309,11 @@ export class WindowInvalidate {
309
309
  const windowToo = reach !== PANES_ONLY;
310
310
  if (layoutChanged) {
311
311
  this.needsLayout = true;
312
+ // What the next walk may skip (`layoutDiff.skipUnreached`): nothing,
313
+ // after a scroll or a change that names no node.
314
+ if (reason === 'scroll' || !source || source === this) {
315
+ this._walkWhole = true;
316
+ }
312
317
  // The content floors are measured from the tree, so anything that
313
318
  // changed it has to give them up — and **scrolling does not**, which is
314
319
  // the whole reason this is not just `needsLayout`: a scroll moves an
@@ -325,8 +330,10 @@ export class WindowInvalidate {
325
330
  // …and which node changed, so that a change inside a box that sizes
326
331
  // itself measures that box and nothing around it (`_floorsScope`).
327
332
  // A claim that names no node, or names the window, is the tree's.
328
- if (source && source !== this) this._floorsSources.add(source);
329
- else this._floorsUnscoped = true;
333
+ if (source && source !== this) {
334
+ this._floorsSources.add(source);
335
+ if (reason !== 'child-list') this._floorsOtherSources.add(source);
336
+ } else this._floorsUnscoped = true;
330
337
  }
331
338
  if (reason !== 'scroll' && reason !== 'resize' && reason !== 'position') {
332
339
  this._floorsContentDirty = true;
@@ -5,6 +5,7 @@
5
5
  import { Yoga } from '../yoga.js';
6
6
  import { callHandler } from '../errors.js';
7
7
  import { DAMAGE_SLOP, layoutDiff } from './damage.js';
8
+ import { resolveBorderWidths } from '../styles.js';
8
9
  import { insetRect, intersectRects, outerPixels, outside } from './rects.js';
9
10
  import { DEV } from './util.js';
10
11
 
@@ -228,22 +229,29 @@ export class NodeLayout {
228
229
  }
229
230
 
230
231
  /**
231
- * A child of a node that moved this pass, which yoga did not lay out
232
- * again: its box and everything under it sit exactly where the last pass
233
- * put them relative to their parents, so the whole subtree moves by
234
- * however far its parent did (`dx`, `dy`) — `_shiftAbs`, the scroll fast
235
- * path's translation (issue #405), instead of the walk. Answers whether
236
- * it was handled; false means walk it.
232
+ * A child yoga did not lay out again: its box and everything under it
233
+ * sit exactly where the last pass put them relative to their parents, so
234
+ * the whole subtree moves by however far its parent did (`dx`, `dy`) —
235
+ * `_shiftAbs`, the scroll fast path's translation (issue #405), instead
236
+ * of the walk — and where its parent did not move, it has not moved at
237
+ * all, and there is nothing to do, unless something scrolled since the
238
+ * last walk (`layoutDiff.skipUnreached`): a scroll moves boxes with no
239
+ * pass, and it is the walk that carries the new offset down to them.
240
+ * Answers whether it was handled; false means walk it.
237
241
  *
238
242
  * The witness is yoga's has-new-layout flag, the one that fast path
239
243
  * reads: a pass sets it on every node it lays out and on every child of
240
244
  * one, cached or not, so a child it is clear on was not reached, nor was
241
245
  * anything under it. Cleared here when it is found set, just before the
242
246
  * walk that reads what the pass left — no pass runs in between — so the
243
- * next move of the same subtree finds it clear. Nothing else pays for
244
- * it: a child of a node that did not move is walked as it always was,
245
- * and a scroll pane always is, since its flag is its own (and a scroll
246
- * moves its children with no pass at all).
247
+ * next look at the same subtree finds it clear. A scroll pane is always
248
+ * walked, since its flag is its own (and a scroll moves its children
249
+ * with no pass at all).
250
+ *
251
+ * Walking a subtree nothing moved was the whole tree for a change in one
252
+ * place: a block that arrived at the end of a document of 13,000 boxes
253
+ * was laid out alone, and the walk still read four numbers out of yoga
254
+ * for every box in the document — 30 ms of a 60 ms frame.
247
255
  *
248
256
  * Walking such a subtree read four numbers out of yoga per node and wrote
249
257
  * them back unchanged: the bodies of a graph's cards, fifty widgets deep,
@@ -255,13 +263,13 @@ export class NodeLayout {
255
263
  * walk's claims came to.
256
264
  */
257
265
  _followParent(dx, dy) {
258
- if (dx === 0 && dy === 0) return false;
259
266
  const yoga = this.yoga;
260
267
  if (!yoga || !this._placed || this.isScroller?.()) return false;
261
268
  if (yoga.hasNewLayout()) {
262
269
  yoga.markLayoutSeen();
263
270
  return false;
264
271
  }
272
+ if (dx === 0 && dy === 0) return layoutDiff.skipUnreached;
265
273
  const sink = layoutDiff.sink;
266
274
  const shift = layoutDiff.shift;
267
275
  if (
@@ -513,12 +521,33 @@ export class NodeLayout {
513
521
  ) {
514
522
  return;
515
523
  }
524
+ // …and one that rode it and only grew or shrank changed the pixels
525
+ // along the edges that moved — the column a virtualized document's
526
+ // blocks sit in, whose spacers re-slice in the scroll's own frame
527
+ const bands =
528
+ had && was.x === x && was.y === y && width > 0 && height > 0
529
+ ? this._edgeBands(was, grow)
530
+ : null;
531
+ if (bands) {
532
+ for (const band of bands) layoutDiff.sink(band, this);
533
+ return;
534
+ }
516
535
  if (had) layoutDiff.sink(insetRect(was, -grow), this);
517
536
  if (width > 0 && height > 0) {
518
537
  layoutDiff.sink(insetRect(this.abs, -grow), this);
519
538
  }
520
539
  return;
521
540
  }
541
+ // A box that stayed where it was and only grew or shrank changed the
542
+ // pixels along the edges that moved, and no others (`_edgeBands`)
543
+ const bands =
544
+ had && old.x === x && old.y === y && width > 0 && height > 0
545
+ ? this._edgeBands(old, grow)
546
+ : null;
547
+ if (bands) {
548
+ for (const band of bands) layoutDiff.sink(band, this);
549
+ return;
550
+ }
522
551
  if (had) {
523
552
  layoutDiff.sink(insetRect(old, -grow), this);
524
553
  }
@@ -528,6 +557,64 @@ export class NodeLayout {
528
557
  }
529
558
  }
530
559
 
560
+ /**
561
+ * The pixels a resize changed, for a box whose origin held: a band along
562
+ * each edge that moved, from where it was to where it is, as deep as the
563
+ * box's paint reaches in from that edge — a rounded corner, a border, a
564
+ * shadow's or an outline's reach. Its own paint anywhere else is the same
565
+ * solid fill it was, and its children claim for themselves when they move.
566
+ *
567
+ * A tall box that grows at its far end is the case: a document or a log
568
+ * or a virtualized list whose column gained a block far below the fold
569
+ * claimed the column, clipped to its scroll box — the whole viewport — on
570
+ * every frame it grew, for pixels that were all out of sight.
571
+ *
572
+ * Null where the whole box has to be claimed: anything that is not a
573
+ * plain box, one that scrolls (its bars are sized by it), one whose
574
+ * gradient is stretched over it, and one drawn through a group surface.
575
+ */
576
+ _edgeBands(old, grow) {
577
+ if (this.kind !== 'box' || this.isScroller?.()) return null;
578
+ const style = this.style;
579
+ if (style.backgroundImage) return null;
580
+ if (style.opacity !== undefined && style.opacity < 1) return null;
581
+ const bw = resolveBorderWidths(style, this.direction);
582
+ const reach =
583
+ Math.max(
584
+ style.borderRadius ?? 0,
585
+ bw.top,
586
+ bw.right,
587
+ bw.bottom,
588
+ bw.left,
589
+ grow,
590
+ ) + DAMAGE_SLOP;
591
+ const now = this.abs;
592
+ const bands = [];
593
+ const oldBottom = old.y + old.height;
594
+ const newBottom = now.y + now.height;
595
+ if (oldBottom !== newBottom) {
596
+ const top = Math.min(oldBottom, newBottom) - reach;
597
+ bands.push({
598
+ x: now.x - reach,
599
+ y: top,
600
+ width: Math.max(old.width, now.width) + 2 * reach,
601
+ height: Math.max(oldBottom, newBottom) + reach - top,
602
+ });
603
+ }
604
+ const oldRight = old.x + old.width;
605
+ const newRight = now.x + now.width;
606
+ if (oldRight !== newRight) {
607
+ const left = Math.min(oldRight, newRight) - reach;
608
+ bands.push({
609
+ x: left,
610
+ y: now.y - reach,
611
+ width: Math.max(oldRight, newRight) + reach - left,
612
+ height: Math.max(old.height, now.height) + 2 * reach,
613
+ });
614
+ }
615
+ return bands;
616
+ }
617
+
531
618
  /**
532
619
  * Move an already-laid-out subtree by a constant, without asking yoga
533
620
  * anything — the scroll fast path's walk (issue #405).
package/src/nodes/node.js CHANGED
@@ -384,8 +384,10 @@ export class Node {
384
384
  // captured before the child joins, so it covers the arrangement that is
385
385
  // about to be replaced (see _childListChanged). A viewport mid-blit has
386
386
  // nothing vacating — the child being added had no pixels — and the
387
- // layout diff claims where it lands, so it names no region at all.
388
- const before = this._blitLedgerOpen() ? null : this._childListBefore();
387
+ // layout diff claims where it lands, so it names no region at all. A
388
+ // child already here is a move, and says so (`_childListFine`).
389
+ const fine = this._childListFine(child.parent !== this);
390
+ const before = fine ? null : this._childListBefore();
389
391
  // a move has to leave the yoga tree too — yoga aborts on insertChild of
390
392
  // a node that still has a parent
391
393
  if (child.parent === this && this._joinsYoga(child)) {
@@ -414,7 +416,7 @@ export class Node {
414
416
  // against the app's, and only now can see the `scale` props above it.
415
417
  child._rescaleSubtree(mounting);
416
418
  this._textContentChanged();
417
- this._childListChanged(before);
419
+ this._childListChanged(before, fine);
418
420
  a11yHooks.attached?.(this, child);
419
421
  }
420
422
 
@@ -464,7 +466,7 @@ export class Node {
464
466
  * through the layout diff in flush(), which is what lets this claim stay
465
467
  * bounded without requiring the node's own size to be pinned.
466
468
  */
467
- _childListChanged(before) {
469
+ _childListChanged(before, fine = this._childListFine(true)) {
468
470
  // belt for a subtree attached imperatively with its rect already laid
469
471
  // out — nothing then re-runs _assignAbs to notice the reach grew
470
472
  this._clearHitBounds();
@@ -477,7 +479,7 @@ export class Node {
477
479
  // and says nothing about the ones that only rode the scroll. Joining
478
480
  // `_reflowed` would undo both — its post-layout claim is this node's
479
481
  // box, the whole band the blit is about to move.
480
- if (this._blitLedgerOpen()) {
482
+ if (fine) {
481
483
  root.invalidate(true, before ?? NO_DAMAGE, 'child-list', this);
482
484
  return;
483
485
  }
@@ -485,6 +487,39 @@ export class Node {
485
487
  root._reflowed.add(this);
486
488
  }
487
489
 
490
+ /**
491
+ * Whether a child-list change here is claimed the fine way — the child
492
+ * that leaves claims its own rect, and the layout diff names where an
493
+ * entering child lands and whatever its arrival displaced — rather than
494
+ * as this node's box before and after the pass.
495
+ *
496
+ * Always for a viewport keeping a ledger this frame (issue #398). And,
497
+ * for a child that enters or leaves (`entersOrLeaves`), for any node
498
+ * inside a scroll box, scrolling or not: a document's blocks, the rows
499
+ * of a virtualized list one box down from its scroll box, a log that
500
+ * grows. Such a node is usually taller than the view, and its box
501
+ * clipped to the viewport is the whole viewport — so every re-slice of
502
+ * a list inside a column, all of it below the fold, repainted the view,
503
+ * and one landing in a scroll's frame poisoned its blit. The diff runs
504
+ * over every node that pass laid out, however deep, and a node that
505
+ * only grew claims the edge that moved (`_edgeBands`).
506
+ *
507
+ * Not for a move: a child moved within the list keeps its rect when
508
+ * what changed is only which sibling it is painted over, and no rect
509
+ * says that. Nor for a scroll box's own children outside a ledger frame:
510
+ * its box is also the claim that repaints its bars, which its content
511
+ * sizes.
512
+ */
513
+ _childListFine(entersOrLeaves) {
514
+ if (this._blitLedgerOpen()) return true;
515
+ if (!entersOrLeaves) return false;
516
+ for (let n = this.parent; n; n = n.parent) {
517
+ if (n.isWindow) return false;
518
+ if (n.isScroller?.()) return true;
519
+ }
520
+ return false;
521
+ }
522
+
488
523
  removeChild(child) {
489
524
  const index = this._indexOfChild(child);
490
525
  if (index === -1) return;
@@ -494,7 +529,8 @@ export class Node {
494
529
  // captured while the child is still attached, so it covers the rect the
495
530
  // child is about to stop occupying — the child's own, for a viewport
496
531
  // mid-blit, where this node's box is the whole scrolled band
497
- const before = this._blitLedgerOpen()
532
+ const fine = this._childListFine(true);
533
+ const before = fine
498
534
  ? (child._claimBounds() ?? NO_DAMAGE)
499
535
  : this._childListBefore();
500
536
  this.children.splice(index, 1);
@@ -515,7 +551,7 @@ export class Node {
515
551
  child.yoga = null;
516
552
  }
517
553
  this._textContentChanged();
518
- this._childListChanged(before);
554
+ this._childListChanged(before, fine);
519
555
  }
520
556
 
521
557
  /** Destroy real resources (X windows) in this subtree. Yoga nodes are
@@ -197,6 +197,7 @@ export class NodeQueries {
197
197
  this.root._floorsDirty = true;
198
198
  this.root._floorsContentDirty = true;
199
199
  this.root._floorsSources.add(this);
200
+ this.root._floorsOtherSources.add(this);
200
201
  }
201
202
  }
202
203
  }
@@ -30,6 +30,56 @@ import { BLIT_POISONED } from './scrollblit.js';
30
30
 
31
31
  const clampScroll = (v, max) => Math.min(Math.max(0, v), max);
32
32
 
33
+ /**
34
+ * How far everything under `node` reaches from its origin — the leftmost
35
+ * edge, the rightmost, the lowest — through every box that does not clip
36
+ * what it holds: the walk `measureScrollContent` sums a pane's extent from.
37
+ *
38
+ * Kept on each box (`_contentReach`) and reused while yoga has laid nothing
39
+ * under it out again. Its has-new-layout flag is set on every box a pass
40
+ * lays out and on each child of one, and a change anywhere below dirties
41
+ * every box above it, so a clear flag is a subtree whose boxes sit where
42
+ * they did relative to it — the same witness `_followParent` reads, read
43
+ * here before the walk that clears it. Hiding a box, or clipping one, goes
44
+ * through yoga too (`display`, `overflow`). A layout host places its
45
+ * children where yoga cannot see, so neither it nor they keep one.
46
+ *
47
+ * Walked whole, a pane's content cost every box in it on every pass that
48
+ * laid out any of it: a block arriving at the end of a document of 13,000
49
+ * boxes read three numbers out of yoga for each of them.
50
+ */
51
+ function contentReach(node, top) {
52
+ const cached = node._contentReach;
53
+ if (
54
+ !top &&
55
+ cached !== undefined &&
56
+ node._host === null &&
57
+ node.parent?._host === null &&
58
+ !node.yoga.hasNewLayout()
59
+ ) {
60
+ return cached;
61
+ }
62
+ let left = Infinity;
63
+ let right = -Infinity;
64
+ let bottom = -Infinity;
65
+ for (const child of node.children) {
66
+ if (child.isWindow || !child.yoga || child.hidden) continue;
67
+ const offset = offsetInParent(child);
68
+ left = Math.min(left, offset.x);
69
+ right = Math.max(right, offset.x + child.yoga.getComputedWidth());
70
+ bottom = Math.max(bottom, offset.y + child.yoga.getComputedHeight());
71
+ if (!child.clipsChildren()) {
72
+ const inner = contentReach(child, false);
73
+ left = Math.min(left, offset.x + inner.left);
74
+ right = Math.max(right, offset.x + inner.right);
75
+ bottom = Math.max(bottom, offset.y + inner.bottom);
76
+ }
77
+ }
78
+ const reach = { left, right, bottom };
79
+ if (!top) node._contentReach = reach;
80
+ return reach;
81
+ }
82
+
33
83
  /**
34
84
  * One axis of a scroll request held for a pass
35
85
  * (`Scrollable._holdScrollTo`): `{ base, steps }`, the request the extent in
@@ -435,21 +485,9 @@ export const Scrollable = (Base) =>
435
485
  measureScrollContent() {
436
486
  const rtl = this.direction === 'rtl';
437
487
  const width = this.yoga.getComputedWidth();
438
- let start = 0;
439
- let bottom = 0;
440
- const walk = (node, dx, dy) => {
441
- for (const child of node.children) {
442
- if (child.isWindow || !child.yoga || child.hidden) continue;
443
- const offset = offsetInParent(child);
444
- const x = dx + offset.x;
445
- const y = dy + offset.y;
446
- const w = child.yoga.getComputedWidth();
447
- start = Math.max(start, rtl ? width - x : x + w);
448
- bottom = Math.max(bottom, y + child.yoga.getComputedHeight());
449
- if (!child.clipsChildren()) walk(child, x, y);
450
- }
451
- };
452
- walk(this, 0, 0);
488
+ const reach = contentReach(this, true);
489
+ const start = Math.max(0, rtl ? width - reach.left : reach.right);
490
+ const bottom = Math.max(0, reach.bottom);
453
491
  // the end padding is part of the content box a browser scrolls to, and
454
492
  // it is the one part of it yoga has already resolved for us — on the
455
493
  // left in RTL, since that is the end there
@@ -855,7 +855,15 @@ export class WindowScrollBlit {
855
855
  ? unionArea([strip, ...repairs]) - rectArea(strip)
856
856
  : 0;
857
857
  if (repairArea > area * BLIT_MAX_CLAIM_AREA) return;
858
- if (!this._scrollBlitSafe(node, vp, target.top)) return;
858
+ // An ancestor's rounded corners reach into the viewport: a scroll box
859
+ // filling a rounded card, the commonest place a scroll box is. Their
860
+ // arcs do not translate, but they live in four small squares and
861
+ // nowhere else, so they are pinned the way an element blit pins them
862
+ // (issue #691) and repaired below, where they are and where the copy
863
+ // dragged their image. Refused instead, a list or a document inside a
864
+ // rounded frame repainted its whole viewport on every step of a scroll.
865
+ const corners = this._cornerPins(node, vp, target.top);
866
+ if (!this._scrollBlitSafe(node, vp, target.top, null, corners)) return;
859
867
  // The band the scrolled bar's thumb travels in is repaired on its own.
860
868
  // The thumb's rects are thin and run along the viewport's edge, and a
861
869
  // rect reaching into the band from inside — a column held down the
@@ -932,10 +940,28 @@ export class WindowScrollBlit {
932
940
  if (dragged) add(dragged);
933
941
  }
934
942
  for (const repair of repairs) add(repair);
935
- rects = joinAlongEdges(rects).reduce(
936
- (capped, rect) => addDamageRect(capped, rect),
937
- [],
938
- );
943
+ // Each pinned corner where it is and where the copy put its image — one
944
+ // box, since a step moves it a few pixels, and less the strip, which
945
+ // repaints anyway.
946
+ for (const pin of corners ?? []) {
947
+ const image = { ...pin, x: pin.x + dx, y: pin.y + dy };
948
+ const repair = intersectRects(unionRect(pin, image), vp);
949
+ if (!repair) continue;
950
+ for (const piece of outside(repair, strip)) add(piece);
951
+ }
952
+ // Capped in one call where corners were pinned: two at each end of the
953
+ // viewport and the strip at one of them are more pieces than the cap
954
+ // holds, and squeezed under it one at a time a corner joined whatever
955
+ // box an earlier merge had grown — scrolling back up merged the bottom
956
+ // corners with the strip at the top, which is the viewport. Offered
957
+ // together, neighbours merge first (`addDamageRects`), with the room an
958
+ // element blit's pins get.
959
+ rects = corners
960
+ ? addDamageRects([], joinAlongEdges(rects), CONTENTS_BLIT_MAX_RECTS)
961
+ : joinAlongEdges(rects).reduce(
962
+ (capped, rect) => addDamageRect(capped, rect),
963
+ [],
964
+ );
939
965
  // The last gate, and the only one that has to wait until the rects are
940
966
  // assembled: damage rects must not overlap, and the frame carries at
941
967
  // most MAX_DAMAGE_RECTS of them, so repairs that meet — a column and a
@@ -0,0 +1,65 @@
1
+ // Measuring a box alone without unsettling the tree around it.
2
+ //
3
+ // The renderer lays everything out in one yoga config, rounded to the pixel
4
+ // grid, and a measurement switches the rounding off (`measuringExactly`,
5
+ // styles.js), which yoga answers by treating every layout it has cached as
6
+ // stale: the pass after any measurement lays the whole tree out again. On a
7
+ // document of 13,000 boxes that pass is 58 ms, where one yoga answers from
8
+ // its cache is 0.05 ms. A measurement over the whole tree pays it anyway,
9
+ // having just laid everything out three times; a box measured alone — a
10
+ // card (`_measureBoundary`), a block on a column spine
11
+ // (`_measureSpineRoot`) — is a few dozen boxes, and made the pass after it
12
+ // cost the whole tree.
13
+ //
14
+ // So a box measured alone is measured on copies: its boxes and everything
15
+ // under them, copied into a config that is never rounded
16
+ // (`exactLayoutConfig`), laid out there, read, and freed. The config the tree
17
+ // is laid out in never changes, and yoga's record of the real boxes — their
18
+ // caches, what is dirty — is not touched.
19
+
20
+ import { Yoga } from '../../yoga.js';
21
+ import { exactLayoutConfig, measuringInExactConfig } from '../../styles.js';
22
+
23
+ /**
24
+ * Run `measure` with `root` and everything under it standing on copies of
25
+ * their boxes: every node's `yoga` is its copy for the duration, so the
26
+ * floors code measures, reads and writes extents exactly as it would on the
27
+ * real boxes.
28
+ *
29
+ * What a measurement writes on a node stays — its extents. What it writes on
30
+ * a box — a floor taken off to measure what is under it, a shrink it
31
+ * borrows, a width it pins — goes with the copy, so the floors' record of
32
+ * what each real box holds (`_floorMinW`/`_floorMinH`) is put back, and the
33
+ * floors written after the measurement are judged against the real box.
34
+ */
35
+ export function onExactCopy(root, measure) {
36
+ const swapped = [];
37
+ const copy = (node) => {
38
+ const real = node.yoga;
39
+ const box = Yoga.Node.create(exactLayoutConfig());
40
+ box.copyStyle(real);
41
+ if (node._measureFn) box.setMeasureFunc(node._measureFn);
42
+ swapped.push({ node, real, minW: node._floorMinW, minH: node._floorMinH });
43
+ let at = 0;
44
+ // the same children yoga has (`Node._joinsYoga`)
45
+ if (node._host === null) {
46
+ for (const child of node.children) {
47
+ if (!child.yoga || child.isWindow) continue;
48
+ box.insertChild(copy(child), at++);
49
+ }
50
+ }
51
+ node.yoga = box;
52
+ return box;
53
+ };
54
+ const top = copy(root);
55
+ try {
56
+ return measuringInExactConfig(measure);
57
+ } finally {
58
+ for (const { node, real, minW, minH } of swapped) {
59
+ node.yoga = real;
60
+ node._floorMinW = minW;
61
+ node._floorMinH = minH;
62
+ }
63
+ top.freeRecursive();
64
+ }
65
+ }
@@ -94,7 +94,7 @@ export function inFlow(node) {
94
94
  }
95
95
 
96
96
  /** Which axis this node lays its children out along. */
97
- const mainAxisOf = (node) => {
97
+ export const mainAxisOf = (node) => {
98
98
  const direction = node.style.flexDirection ?? 'column';
99
99
  return direction === 'row' || direction === 'row-reverse'
100
100
  ? 'width'
@@ -107,7 +107,7 @@ const mainAxisOf = (node) => {
107
107
  * content. The same test `writeFloors` applies, asked ahead of time — it is
108
108
  * what decides whether a stale extent is one anybody will read.
109
109
  */
110
- function receivesFloor(child, axis) {
110
+ export function receivesFloor(child, axis) {
111
111
  const parent = child.parent;
112
112
  if (
113
113
  !parent ||
@@ -279,6 +279,80 @@ export function dirtyOutside(node, boundaries, paths) {
279
279
  return false;
280
280
  }
281
281
 
282
+ /**
283
+ * `contentSpan`'s height for a column, from the extents its children
284
+ * already carry rather than from a measuring pass — the sum a column spine
285
+ * (`spine.js`) is recomputed with, bottom up, where a pass would lay the
286
+ * whole tree out to read it.
287
+ *
288
+ * It is the same number. In a measuring pass `contentSpan` places each
289
+ * child at the sum of the extents before it (its `shift`), so what it reads
290
+ * back is every in-flow child's extent, the margins *between* them — the
291
+ * first child's leading margin and the last one's trailing margin fall
292
+ * outside the span, which runs from the first box to the last — the gap
293
+ * between each pair, and the column's own padding and border. That holds
294
+ * for a column whose height is its content's, which is the only kind a
295
+ * spine sums (`isSummableColumn`): with no height of its own the pass has
296
+ * no free space to justify into, and nothing in it to squash.
297
+ *
298
+ * Undefined where it cannot be summed: a child with no extent on hand, a
299
+ * negative margin (the boxes would overlap, and the last would not have to
300
+ * be the lowest), or nothing in flow at all — a leaf's answer, which is
301
+ * `contentSpan`'s to give.
302
+ */
303
+ export function columnHeightSpan(node) {
304
+ const yoga = node.yoga;
305
+ let span = 0;
306
+ let count = 0;
307
+ let trailing = 0;
308
+ for (const child of node.children) {
309
+ if (!inFlow(child)) continue;
310
+ const extent = child._floorH;
311
+ if (extent === undefined) return undefined;
312
+ const top = marginDown(child.yoga, Yoga.EDGE_TOP);
313
+ const bottom = marginDown(child.yoga, Yoga.EDGE_BOTTOM);
314
+ if (!(top >= 0 && bottom >= 0)) return undefined;
315
+ span += count === 0 ? extent : trailing + top + extent;
316
+ trailing = bottom;
317
+ count += 1;
318
+ }
319
+ if (count === 0) return undefined;
320
+ const gap = columnGap(node);
321
+ if (gap === undefined) return undefined;
322
+ return (
323
+ span +
324
+ gap * (count - 1) +
325
+ yoga.getComputedPadding(Yoga.EDGE_TOP) +
326
+ yoga.getComputedPadding(Yoga.EDGE_BOTTOM) +
327
+ yoga.getComputedBorder(Yoga.EDGE_TOP) +
328
+ yoga.getComputedBorder(Yoga.EDGE_BOTTOM)
329
+ );
330
+ }
331
+
332
+ /**
333
+ * A box's margin on one edge down the page, from its style as yoga resolves
334
+ * it — the edge, else the vertical pair, else all four — rather than from a
335
+ * pass: a box that has just arrived has never been laid out in the tree, and
336
+ * its computed margins read 0 until it is. Undefined where it is not a
337
+ * length (a percentage, `auto`).
338
+ */
339
+ function marginDown(yoga, edge) {
340
+ for (const at of [edge, Yoga.EDGE_VERTICAL, Yoga.EDGE_ALL]) {
341
+ const value = yoga.getMargin(at);
342
+ if (value.unit === Yoga.UNIT_POINT) return value.value;
343
+ if (value.unit !== Yoga.UNIT_UNDEFINED) return undefined;
344
+ }
345
+ return 0;
346
+ }
347
+
348
+ /** The gap a column leaves between its rows, as yoga resolves it — the row
349
+ * gutter, else the both-ways one — or undefined where it is not a length. */
350
+ function columnGap(node) {
351
+ const style = node.style;
352
+ const gap = style.rowGap ?? style.gap ?? 0;
353
+ return typeof gap === 'number' ? gap : undefined;
354
+ }
355
+
282
356
  /**
283
357
  * After a pass that settled the widths: which of the height floors, each a
284
358
  * height *for a width*, were measured for a width their node no longer has.
@@ -212,6 +212,9 @@ export class WindowFlush {
212
212
  }
213
213
  // the moves this walk hands over to be copied are this frame's alone
214
214
  this._rigidMoves = null;
215
+ // a subtree no pass reached and nothing scrolled in is where the last
216
+ // walk left it (`_followParent`)
217
+ layoutDiff.skipUnreached = !this._walkWhole;
215
218
  try {
216
219
  // `_absolutizeChildren`, not the loop it wraps: a `<window
217
220
  // style={{overflow: 'scroll'}}>` is a scroll container like any box,
@@ -219,7 +222,9 @@ export class WindowFlush {
219
222
  this._absolutizeChildren(0, 0);
220
223
  } finally {
221
224
  layoutDiff.sink = null;
225
+ layoutDiff.skipUnreached = false;
222
226
  }
227
+ this._walkWhole = false;
223
228
  // …and placed nodes against the arrangement that walk produced: where
224
229
  // one goes depends on where its pane and its parent landed
225
230
  if (this._placedNodes.size !== 0) this._placeNodes();
@@ -8,9 +8,12 @@ import { Yoga } from '../../yoga.js';
8
8
  import {
9
9
  captureLeafHeights,
10
10
  collectFloorStale,
11
+ columnHeightSpan,
12
+ declaresOwnMinimum,
11
13
  dirtyOutside,
12
14
  floorBoundaryOf,
13
15
  probeHeightFloors,
16
+ receivesFloor,
14
17
  setMeasuringShrink,
15
18
  restoreShrink,
16
19
  freezeWidths,
@@ -18,6 +21,8 @@ import {
18
21
  contentSpan,
19
22
  writeFloors,
20
23
  } from './floors.js';
24
+ import { spineScope } from './spine.js';
25
+ import { onExactCopy } from './exactcopy.js';
21
26
  import {
22
27
  MAX_WINDOW_EXTENT,
23
28
  clampExtent,
@@ -36,18 +41,14 @@ import {
36
41
  // REACT_X11_NO_SCROLL_BLIT gives.
37
42
  const NO_SCOPED_FLOORS = process.env.REACT_X11_NO_SCOPED_FLOORS === '1';
38
43
 
39
- /**
40
- * Mark a box laid out alone (`_measureBoundary`) for the window's pass to lay
41
- * out again. It was laid out at the size that pass will give it, so yoga
42
- * would answer from its cache and keep what the measurement left inside —
43
- * placed off the pixel grid, with the shrink borrowed. A style set to
44
- * something else and back is how a box with no measure function says it
45
- * changed.
46
- */
47
- function relayOut(node) {
48
- const shrink = node.style.flexShrink ?? 1;
49
- node.yoga.setFlexShrink(shrink + 1);
50
- node.yoga.setFlexShrink(shrink);
44
+ /** Take a node's extents off, as `collectFloorStale` does a dirty child's:
45
+ * both are measured again, and the minimum yoga holds may no longer be
46
+ * the one the floors wrote. */
47
+ function forgetExtents(node) {
48
+ node._floorW = undefined;
49
+ node._floorH = undefined;
50
+ node._floorMinW = null;
51
+ node._floorMinH = null;
51
52
  }
52
53
 
53
54
  /** Content-sized windows, installed onto `WindowNode.prototype` by window.js. */
@@ -132,12 +133,43 @@ export class WindowSize {
132
133
  const scope = this._floorsScope();
133
134
  this._floorsScopeNow = scope;
134
135
  if (scope !== null) {
136
+ const spine = this._floorsSpineNow;
135
137
  for (const boundary of scope) {
136
- // its children's floors are written from here; its own extent is
137
- // its style's, and stands
138
+ if (spine?.roots.has(boundary)) {
139
+ // A spine's root is measured with what is inside it, unlike a box
140
+ // that sizes itself, and its column writes the floor it gets.
141
+ forgetExtents(boundary);
142
+ if (receivesFloor(boundary, 'width')) found.width = true;
143
+ if (receivesFloor(boundary, 'height')) found.height = true;
144
+ this._floorsStale.add(boundary.parent);
145
+ }
146
+ // its children's floors are written from here; a sized box's own
147
+ // extent is its style's, and stands
138
148
  this._floorsStale.add(boundary);
139
149
  collectFloorStale(boundary, this._floorsStale, found, false);
140
150
  }
151
+ if (spine !== null) {
152
+ // the columns the roots sit in are summed again (`_sumSpine`), and
153
+ // every one of them writes its children's floors, as the box each
154
+ // spine stops at does
155
+ for (const column of spine.spine) {
156
+ column._floorW = undefined;
157
+ column._floorH = undefined;
158
+ this._floorsStale.add(column);
159
+ if (receivesFloor(column, 'height')) found.height = true;
160
+ }
161
+ for (const stop of spine.stops) this._floorsStale.add(stop);
162
+ // How wide a root is in the width pass is worked out on the way
163
+ // down from the window (spine.js), and past a row only a pass over
164
+ // the whole tree knows. A width owed there is the whole tree's.
165
+ if (found.width) {
166
+ for (const widths of spine.roots.values()) {
167
+ if (widths.widthPass !== null) continue;
168
+ this._floorsUnscoped = true;
169
+ return this._collectFloorStale();
170
+ }
171
+ }
172
+ }
141
173
  return found;
142
174
  }
143
175
  // the root's own children are written from here too, and its direction
@@ -189,16 +221,37 @@ export class WindowSize {
189
221
  ) {
190
222
  return null;
191
223
  }
224
+ this._floorsSpineNow = null;
192
225
  const boundaries = new Set();
226
+ // the changes no sized box holds, which a column spine may (spine.js)
227
+ const loose = [];
193
228
  for (const source of sources) {
194
229
  // gone, or moved to another window: its old parent's child list is
195
230
  // the change that counts, and that parent said so itself
196
231
  if (source.destroyed || source.root !== this) continue;
197
232
  const boundary = floorBoundaryOf(source, this);
198
- if (boundary === null) return null;
199
- boundaries.add(boundary);
233
+ if (boundary === null) loose.push(source);
234
+ else boundaries.add(boundary);
235
+ }
236
+ let spine = null;
237
+ if (loose.length !== 0) {
238
+ spine = spineScope(
239
+ this,
240
+ loose,
241
+ (node) => !this._floorsOtherSources.has(node),
242
+ );
243
+ if (spine === null) return null;
244
+ // a sized box inside a root is measured with the root
245
+ for (const boundary of [...boundaries]) {
246
+ for (let n = boundary.parent; n && n !== this; n = n.parent) {
247
+ if (spine.roots.has(n)) {
248
+ boundaries.delete(boundary);
249
+ break;
250
+ }
251
+ }
252
+ }
200
253
  }
201
- if (boundaries.size === 0) return null;
254
+ if (boundaries.size === 0 && spine === null) return null;
202
255
  // the outermost of nested ones: measuring it measures what it holds
203
256
  const paths = new Set();
204
257
  for (const boundary of [...boundaries]) {
@@ -214,10 +267,94 @@ export class WindowSize {
214
267
  paths.add(n);
215
268
  }
216
269
  }
270
+ if (spine !== null) {
271
+ for (const root of spine.roots.keys()) {
272
+ boundaries.add(root);
273
+ for (let n = root.parent; n && n !== this; n = n.parent) paths.add(n);
274
+ }
275
+ // the columns summed, and the stops with the way down to them: a
276
+ // child that left one is a change to it, and nothing below it
277
+ for (const column of spine.spine) paths.add(column);
278
+ for (const stop of spine.stops) {
279
+ for (let n = stop; n && n !== this; n = n.parent) paths.add(n);
280
+ }
281
+ }
217
282
  if (dirtyOutside(this, boundaries, paths)) return null;
283
+ this._floorsSpineNow = spine;
218
284
  return boundaries;
219
285
  }
220
286
 
287
+ /**
288
+ * One spine root's floors, measured with it laid out alone — at the width
289
+ * it has in each of the passes over the whole tree (`spine.js`), and with
290
+ * no height on offer in either height pass, because down a column whose
291
+ * height is its content's the pass over the whole tree has none to offer
292
+ * it either. Its own extents are written here: unlike a box that sizes
293
+ * itself, a root's are its content's, and the column it sits in is summed
294
+ * from them (`_sumSpine`).
295
+ */
296
+ _measureSpineRoot(root, widths, axis, probe = false) {
297
+ onExactCopy(root, () =>
298
+ this._measureSpineRootOnCopy(root, widths, axis, probe),
299
+ );
300
+ }
301
+
302
+ _measureSpineRootOnCopy(root, widths, axis, probe) {
303
+ const yoga = root.yoga;
304
+ const dir =
305
+ root.direction === 'rtl' ? Yoga.DIRECTION_RTL : Yoga.DIRECTION_LTR;
306
+ if (axis === 'width') {
307
+ const shrunk = [];
308
+ setMeasuringShrink(root, axis, shrunk);
309
+ this._scopedFloorPasses += 1;
310
+ yoga.calculateLayout(widths.widthPass, undefined, dir);
311
+ const span = contentSpan(root, axis, null, this);
312
+ root._floorW = declaresOwnMinimum(root, axis)
313
+ ? yoga.getComputedWidth()
314
+ : span;
315
+ restoreShrink(shrunk);
316
+ return;
317
+ }
318
+ this._scopedFloorPasses += 1;
319
+ yoga.calculateLayout(widths.width, undefined, dir);
320
+ if (probe) {
321
+ const hit = { marked: false, owed: false };
322
+ probeHeightFloors(root, this, hit, root);
323
+ if (hit.marked) {
324
+ this._writeFloors('height');
325
+ this._scopedFloorPasses += 1;
326
+ yoga.calculateLayout(widths.width, undefined, dir);
327
+ }
328
+ }
329
+ const intrinsic = new Map();
330
+ captureLeafHeights(root, intrinsic);
331
+ const frozen = [];
332
+ freezeWidths(root, frozen);
333
+ const shrunk = [];
334
+ setMeasuringShrink(root, axis, shrunk);
335
+ this._scopedFloorPasses += 1;
336
+ yoga.calculateLayout(widths.width, undefined, dir);
337
+ const span = contentSpan(root, axis, intrinsic, this);
338
+ root._floorH = declaresOwnMinimum(root, axis)
339
+ ? yoga.getComputedHeight()
340
+ : span;
341
+ root._floorAtW = yoga.getComputedWidth();
342
+ this._floorsMeasured += 1;
343
+ restoreWidths(frozen);
344
+ restoreShrink(shrunk);
345
+ }
346
+
347
+ /** The columns of this frame's spines, deepest first, summed from their
348
+ * children's extents (`columnHeightSpan`) — their widths are the last
349
+ * pass's, which is what a height is for. */
350
+ _sumSpine(columns) {
351
+ for (const column of columns) {
352
+ column._floorH = columnHeightSpan(column);
353
+ column._floorAtW = column.yoga.getComputedWidth();
354
+ this._floorsMeasured += 1;
355
+ }
356
+ }
357
+
221
358
  /**
222
359
  * One boundary's floors, measured with it laid out alone — the passes
223
360
  * `_measureContentSpans` runs over the whole tree, run over the box: its
@@ -226,6 +363,15 @@ export class WindowSize {
226
363
  * leaves whose width moved, stopped at the box, whose own extent stands.
227
364
  */
228
365
  _measureBoundary(boundary, axis, probe = false) {
366
+ // on copies of its boxes (exactcopy.js): the tree's own are left as the
367
+ // last pass cached them, which is what keeps the pass after this one
368
+ // from laying the whole tree out again
369
+ onExactCopy(boundary, () =>
370
+ this._measureBoundaryOnCopy(boundary, axis, probe),
371
+ );
372
+ }
373
+
374
+ _measureBoundaryOnCopy(boundary, axis, probe) {
229
375
  const yoga = boundary.yoga;
230
376
  const dir =
231
377
  boundary.direction === 'rtl' ? Yoga.DIRECTION_RTL : Yoga.DIRECTION_LTR;
@@ -236,7 +382,6 @@ export class WindowSize {
236
382
  yoga.calculateLayout(undefined, undefined, dir);
237
383
  contentSpan(boundary, axis, null, this);
238
384
  restoreShrink(shrunk);
239
- relayOut(boundary);
240
385
  return;
241
386
  }
242
387
  this._scopedFloorPasses += 1;
@@ -261,7 +406,6 @@ export class WindowSize {
261
406
  contentSpan(boundary, axis, intrinsic, this);
262
407
  restoreWidths(frozen);
263
408
  restoreShrink(shrunk);
264
- relayOut(boundary);
265
409
  }
266
410
 
267
411
  /** The floors on the children of every node found stale, from the extents
@@ -386,8 +530,10 @@ export class WindowSize {
386
530
  this._floorsSwept = true;
387
531
  this._floorsWidth = width;
388
532
  this._floorsSources.clear();
533
+ this._floorsOtherSources.clear();
389
534
  this._floorsUnscoped = false;
390
535
  this._floorsScopeNow = null;
536
+ this._floorsSpineNow = null;
391
537
  }
392
538
 
393
539
  /**
@@ -398,16 +544,21 @@ export class WindowSize {
398
544
  * everywhere but the boxes and the way down to them.
399
545
  */
400
546
  _applyScopedFloors(boundaries, found, width, height) {
401
- measuringExactly(() => {
402
- if (found.width) {
403
- this._writeFloors('width');
404
- for (const boundary of boundaries) {
405
- this._measureBoundary(boundary, 'width');
406
- }
407
- }
547
+ const spine = this._floorsSpineNow;
548
+ const measure = (boundary, axis, probe) => {
549
+ const widths = spine?.roots.get(boundary);
550
+ if (widths) this._measureSpineRoot(boundary, widths, axis, probe);
551
+ else this._measureBoundary(boundary, axis, probe);
552
+ };
553
+ // Measured on copies in the exact config (exactcopy.js), so the
554
+ // renderer's config never leaves the grid, and the pass below answers
555
+ // from its cache everywhere these boxes and the way down to them are not.
556
+ if (found.width) {
408
557
  this._writeFloors('width');
409
- this._writeFloors('height');
410
- });
558
+ for (const boundary of boundaries) measure(boundary, 'width');
559
+ }
560
+ this._writeFloors('width');
561
+ this._writeFloors('height');
411
562
  let heights = found.height;
412
563
  let probed = false;
413
564
  if (!heights) {
@@ -420,13 +571,12 @@ export class WindowSize {
420
571
  probed = true;
421
572
  }
422
573
  if (heights) {
423
- measuringExactly(() => {
424
- this._writeFloors('height');
425
- for (const boundary of boundaries) {
426
- this._measureBoundary(boundary, 'height', !probed);
427
- }
428
- this._writeFloors('height');
429
- });
574
+ this._writeFloors('height');
575
+ for (const boundary of boundaries) {
576
+ measure(boundary, 'height', !probed);
577
+ }
578
+ if (spine !== null) this._sumSpine(spine.spine);
579
+ this._writeFloors('height');
430
580
  this._layoutRoot(width, height);
431
581
  }
432
582
  }
@@ -0,0 +1,294 @@
1
+ // Column spines: the scope a change outside every sized box can still be
2
+ // measured in (`WindowNode._floorsScope`), when what stands between it and a
3
+ // box that sizes itself is a run of columns whose height is their content's.
4
+ //
5
+ // A document, a log, a chat transcript, a feed: one scroll pane, one column
6
+ // in it, thousands of blocks in the column. A block that arrives or changes
7
+ // there is confined to nothing `isFloorBoundary` recognises, so it used to
8
+ // be measured with the whole tree — three layout passes over every block,
9
+ // each evicting the one yoga cached for the pass before — to learn the
10
+ // extents of one block and the column it sits in. A spine measures the
11
+ // block alone and sums the column: `columnHeightSpan` over the extents its
12
+ // other children already carry.
13
+ //
14
+ // Three facts make that the same answer the whole tree gives:
15
+ //
16
+ // - Down a column whose height is its content's, the measuring passes have
17
+ // no free space to squash into, so a block in it is laid out at its
18
+ // content height in every pass. Laid out alone with no height on offer,
19
+ // it comes out the same.
20
+ // - Across, a block stretched over its column is as wide as the column's
21
+ // content box, in every pass: now, at the width the heights are measured
22
+ // for, and in the width pass, whose width the way down from the root
23
+ // decides (`widthPassWidth`).
24
+ // - Only heights travel up a spine. A column writes floors down its main
25
+ // axis, and the box a spine stops at names its own size on both axes
26
+ // (`isSpineStop`), so no width extent above the block is ever read. They
27
+ // are left unmeasured, which is what the floors do with every extent
28
+ // nobody reads (`collectFloorStale`).
29
+ //
30
+ // Anything else — a row on the way, a margin across the block, a width
31
+ // that is not a length — is not a spine, and the tree is measured whole.
32
+
33
+ import { declaresOwnMinimum, inFlow, mainAxisOf } from './floors.js';
34
+ import { Yoga } from '../../yoga.js';
35
+
36
+ const LENGTHS_ACROSS = [
37
+ 'margin',
38
+ 'marginHorizontal',
39
+ 'marginLeft',
40
+ 'marginRight',
41
+ 'marginStart',
42
+ 'marginEnd',
43
+ ];
44
+ const MARGINS = [
45
+ ...LENGTHS_ACROSS,
46
+ 'marginVertical',
47
+ 'marginTop',
48
+ 'marginBottom',
49
+ ];
50
+ const PADDINGS = [
51
+ 'padding',
52
+ 'paddingTop',
53
+ 'paddingRight',
54
+ 'paddingBottom',
55
+ 'paddingLeft',
56
+ 'paddingHorizontal',
57
+ 'paddingVertical',
58
+ 'paddingStart',
59
+ 'paddingEnd',
60
+ ];
61
+
62
+ /**
63
+ * A column a spine can sum (`columnHeightSpan`): laid out by flex down its
64
+ * main axis without wrapping, and with a height that is its content's —
65
+ * nothing named on the height axis (`declaresOwnMinimum`), so neither a
66
+ * size, a floor, a ceiling nor a clip stands between its children and its
67
+ * extent.
68
+ */
69
+ export function isSummableColumn(node) {
70
+ const style = node.style;
71
+ return (
72
+ node.yoga != null &&
73
+ !node.isWindow &&
74
+ node._host === null &&
75
+ style.display !== 'none' &&
76
+ mainAxisOf(node) === 'height' &&
77
+ style.flexDirection !== 'column-reverse' &&
78
+ (style.flexWrap == null || style.flexWrap === 'nowrap') &&
79
+ !declaresOwnMinimum(node, 'height')
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Where a change stops mattering on its way up: the window, or a column
85
+ * whose extent on both axes is its style's — a scroll pane is the common
86
+ * one — so that what changed inside it moves nothing above it. A column,
87
+ * because it writes its children's floors down its main axis, and a spine
88
+ * only works out heights.
89
+ */
90
+ export function isSpineStop(node) {
91
+ if (node.isWindow) return true;
92
+ return (
93
+ node.yoga != null &&
94
+ node._host === null &&
95
+ mainAxisOf(node) === 'height' &&
96
+ declaresOwnMinimum(node, 'height') &&
97
+ declaresOwnMinimum(node, 'width')
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The spine scope of this frame's changes, or null when one of them has
103
+ * none. `sources` are the nodes that changed and are inside no sized box;
104
+ * `listOnly(node)` says whether all a node did was gain or lose children.
105
+ *
106
+ * A change is measured from the highest box on its way up that no column
107
+ * above it can sum — a text inside a row inside a list is measured with the
108
+ * row — and everything above that is summed. A column that only gained or
109
+ * lost children is not measured itself: the children that are new or
110
+ * changed are, and it is summed with the rest.
111
+ *
112
+ * Answers `{ roots, spine, stops }`: the boxes measured alone, with the
113
+ * widths to measure each at (`rootWidths`); the columns summed, deepest
114
+ * first; and the boxes each spine ends at, whose children's floors are
115
+ * written.
116
+ */
117
+ export function spineScope(root, sources, listOnly) {
118
+ const roots = new Map();
119
+ const spine = new Set();
120
+ const stops = new Set();
121
+ for (const source of sources) {
122
+ const list = listOnly(source);
123
+ // the way up to where the change stops mattering
124
+ const path = [];
125
+ let node = source;
126
+ while (!isSpineStop(node)) {
127
+ path.push(node);
128
+ node = node.parent;
129
+ if (!node) return null;
130
+ }
131
+ if (node !== root && node.root !== root) return null;
132
+ // a stop changed in itself: its padding, its direction — everything it
133
+ // holds is laid out anew, which is the whole tree's measurement
134
+ if (path.length === 0 && !list) return null;
135
+ stops.add(node);
136
+ // the highest box on the way that a column above it cannot sum
137
+ let top = -1;
138
+ for (let i = path.length - 1; i >= 0; i--) {
139
+ const summed = i > 0 || list;
140
+ if (!summed || !isSummableColumn(path[i])) {
141
+ top = i;
142
+ break;
143
+ }
144
+ }
145
+ for (const column of path.slice(top + 1)) spine.add(column);
146
+ const changed = top === -1 ? staleChildren(source) : [path[top]];
147
+ for (const child of changed) {
148
+ if (!child.yoga || child.isWindow || child.style.display === 'none') {
149
+ continue;
150
+ }
151
+ const widths = rootWidths(child, root);
152
+ if (widths === null) return null;
153
+ roots.set(child, widths);
154
+ }
155
+ }
156
+ // A root inside another root is measured with it, and so is a column it
157
+ // would have been summed through.
158
+ const within = (node) => {
159
+ for (let n = node.parent; n; n = n.parent) if (roots.has(n)) return true;
160
+ return false;
161
+ };
162
+ for (const node of [...roots.keys()]) if (within(node)) roots.delete(node);
163
+ for (const node of [...spine]) {
164
+ if (roots.has(node) || within(node)) spine.delete(node);
165
+ }
166
+ // Every other child of a summed column answers from the extent it carries.
167
+ for (const column of spine) {
168
+ for (const child of column.children) {
169
+ if (!inFlow(child) || roots.has(child) || spine.has(child)) continue;
170
+ if (child._floorH === undefined) return null;
171
+ if (MARGINS.some((key) => typeof child.style[key] === 'string')) {
172
+ return null;
173
+ }
174
+ }
175
+ }
176
+ const depth = (node) => {
177
+ let d = 0;
178
+ for (let n = node; n; n = n.parent) d += 1;
179
+ return d;
180
+ };
181
+ return {
182
+ roots,
183
+ spine: [...spine].sort((a, b) => depth(b) - depth(a)),
184
+ stops,
185
+ };
186
+ }
187
+
188
+ /** The children of a node that gained or lost some: the ones yoga has a
189
+ * change on record for, and the ones never measured. */
190
+ function staleChildren(node) {
191
+ const out = [];
192
+ for (const child of node.children) {
193
+ if (!child.yoga || child.isWindow) continue;
194
+ if (child.yoga.isDirty() || child._floorH === undefined) out.push(child);
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * The widths a box is measured alone at, or null when it cannot be: in flow
201
+ * and stretched across its column with no margin, width or ceiling of its
202
+ * own across it, so that it is exactly as wide as the column's content box.
203
+ * `width` is that box now — the width its heights are for — and
204
+ * `widthPass` the one the width pass over the whole tree would give it
205
+ * (`widthPassWidth`), null where only a pass knows.
206
+ */
207
+ function rootWidths(node, root) {
208
+ const style = node.style;
209
+ const parent = node.parent;
210
+ if (!parent || !inFlow(node) || node._host !== null) return null;
211
+ if (style.width !== undefined || style.maxWidth !== undefined) return null;
212
+ if (typeof style.minWidth === 'string') return null;
213
+ if (!stretched(node)) return null;
214
+ if (
215
+ LENGTHS_ACROSS.some((key) => style[key] !== undefined && style[key] !== 0)
216
+ ) {
217
+ return null;
218
+ }
219
+ if (MARGINS.some((key) => typeof style[key] === 'string')) return null;
220
+ if (PADDINGS.some((key) => typeof style[key] === 'string')) return null;
221
+ return {
222
+ width: contentWidth(parent),
223
+ widthPass: widthPassContent(parent, root),
224
+ };
225
+ }
226
+
227
+ /** Stretched across its parent's column — `alignSelf`, or the column's
228
+ * `alignItems` when the box leaves it to them. */
229
+ function stretched(node) {
230
+ const parent = node.parent;
231
+ if (!parent || mainAxisOf(parent) !== 'height') return false;
232
+ const self = node.style.alignSelf ?? 'auto';
233
+ if (self === 'stretch') return true;
234
+ return (
235
+ self === 'auto' && (parent.style.alignItems ?? 'stretch') === 'stretch'
236
+ );
237
+ }
238
+
239
+ /** Padding and border across, as the last pass resolved them. */
240
+ function insetAcross(node) {
241
+ const yoga = node.yoga;
242
+ return (
243
+ yoga.getComputedPadding(Yoga.EDGE_LEFT) +
244
+ yoga.getComputedPadding(Yoga.EDGE_RIGHT) +
245
+ yoga.getComputedBorder(Yoga.EDGE_LEFT) +
246
+ yoga.getComputedBorder(Yoga.EDGE_RIGHT)
247
+ );
248
+ }
249
+
250
+ /** A box's content width in the last pass. */
251
+ function contentWidth(node) {
252
+ return Math.max(0, node.yoga.getComputedWidth() - insetAcross(node));
253
+ }
254
+
255
+ /**
256
+ * How wide `node` is in the width pass over the whole tree
257
+ * (`_measureContentSpans('width')`), worked out on the way down rather than
258
+ * laid out: the root at nothing, and each box below it either the width its
259
+ * style names or stretched over its column's content box — clamped as yoga
260
+ * clamps it, and never narrower than its own padding and border. Null where
261
+ * the way down is anything else: a row, whose children are as wide as the
262
+ * pass decides.
263
+ */
264
+ function widthPassWidth(node, root) {
265
+ if (node === root) return insetAcross(node);
266
+ const style = node.style;
267
+ let width;
268
+ if (typeof style.width === 'number') width = style.width;
269
+ else if (style.width !== undefined) return null;
270
+ else {
271
+ if (!inFlow(node) || !stretched(node)) return null;
272
+ if (MARGINS.some((key) => typeof style[key] === 'string')) return null;
273
+ const inner = widthPassContent(node.parent, root);
274
+ if (inner === null) return null;
275
+ const yoga = node.yoga;
276
+ width =
277
+ inner -
278
+ yoga.getComputedMargin(Yoga.EDGE_LEFT) -
279
+ yoga.getComputedMargin(Yoga.EDGE_RIGHT);
280
+ }
281
+ if (typeof style.maxWidth === 'number')
282
+ width = Math.min(width, style.maxWidth);
283
+ else if (style.maxWidth !== undefined) return null;
284
+ if (typeof style.minWidth === 'number')
285
+ width = Math.max(width, style.minWidth);
286
+ else if (style.minWidth !== undefined) return null;
287
+ return Math.max(width, insetAcross(node));
288
+ }
289
+
290
+ /** …and the content box inside it. */
291
+ function widthPassContent(node, root) {
292
+ const width = widthPassWidth(node, root);
293
+ return width === null ? null : Math.max(0, width - insetAcross(node));
294
+ }
@@ -277,8 +277,15 @@ export class WindowNode extends Scrollable(Node) {
277
277
  // the nodes whose changes the next measurement owes, and whether one
278
278
  // came with no node to name (`_floorsScope`)
279
279
  this._floorsSources = new Set();
280
+ // …the ones that changed for any reason but their child list, which
281
+ // are measured themselves rather than through the children they gained
282
+ // (spine.js), and the spines this frame's measurement runs on
283
+ this._floorsOtherSources = new Set();
280
284
  this._floorsUnscoped = false;
281
285
  this._floorsScopeNow = null;
286
+ this._floorsSpineNow = null;
287
+ // whether the next layout walk must visit every box (`invalidate`)
288
+ this._walkWhole = true;
282
289
  this._scopedFloorPasses = 0;
283
290
  // whether the first floors pass has run (`collectFloorStale`'s sweep)
284
291
  this._floorsSwept = false;
package/src/styles.js CHANGED
@@ -1950,6 +1950,34 @@ let exactDepth = 0;
1950
1950
  * here is not the size the same layout comes to on it. */
1951
1951
  export const isMeasuringExactly = () => exactDepth > 0;
1952
1952
 
1953
+ /**
1954
+ * A second config, never rounded, for measuring a box on copies of its
1955
+ * boxes (nodes/window/exactcopy.js). Switching the renderer's config off the
1956
+ * grid and back is a change yoga answers by treating every layout it has
1957
+ * cached as stale, so the pass after any `measuringExactly` lays the whole
1958
+ * tree out again; a measurement confined to one box is made in this config
1959
+ * instead, and leaves the renderer's, and its caches, alone.
1960
+ */
1961
+ let exactConfig = null;
1962
+ export const exactLayoutConfig = () => {
1963
+ if (exactConfig === null) {
1964
+ exactConfig = Yoga.Config.create();
1965
+ exactConfig.setPointScaleFactor(0);
1966
+ }
1967
+ return exactConfig;
1968
+ };
1969
+
1970
+ /** `measure` for layouts made in the exact config: off the grid as far as
1971
+ * `isMeasuringExactly` is concerned, with nothing to switch. */
1972
+ export function measuringInExactConfig(measure) {
1973
+ exactDepth++;
1974
+ try {
1975
+ return measure();
1976
+ } finally {
1977
+ exactDepth--;
1978
+ }
1979
+ }
1980
+
1953
1981
  /**
1954
1982
  * The yoga defaults that are not CSS's, written once per node.
1955
1983
  *