react-x11 2.22.2 → 2.22.4

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.2",
3
+ "version": "2.22.4",
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;
@@ -228,22 +228,29 @@ export class NodeLayout {
228
228
  }
229
229
 
230
230
  /**
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.
231
+ * A child yoga did not lay out again: its box and everything under it
232
+ * sit exactly where the last pass put them relative to their parents, so
233
+ * the whole subtree moves by however far its parent did (`dx`, `dy`) —
234
+ * `_shiftAbs`, the scroll fast path's translation (issue #405), instead
235
+ * of the walk — and where its parent did not move, it has not moved at
236
+ * all, and there is nothing to do, unless something scrolled since the
237
+ * last walk (`layoutDiff.skipUnreached`): a scroll moves boxes with no
238
+ * pass, and it is the walk that carries the new offset down to them.
239
+ * Answers whether it was handled; false means walk it.
237
240
  *
238
241
  * The witness is yoga's has-new-layout flag, the one that fast path
239
242
  * reads: a pass sets it on every node it lays out and on every child of
240
243
  * one, cached or not, so a child it is clear on was not reached, nor was
241
244
  * anything under it. Cleared here when it is found set, just before the
242
245
  * 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).
246
+ * next look at the same subtree finds it clear. A scroll pane is always
247
+ * walked, since its flag is its own (and a scroll moves its children
248
+ * with no pass at all).
249
+ *
250
+ * Walking a subtree nothing moved was the whole tree for a change in one
251
+ * place: a block that arrived at the end of a document of 13,000 boxes
252
+ * was laid out alone, and the walk still read four numbers out of yoga
253
+ * for every box in the document — 30 ms of a 60 ms frame.
247
254
  *
248
255
  * Walking such a subtree read four numbers out of yoga per node and wrote
249
256
  * them back unchanged: the bodies of a graph's cards, fifty widgets deep,
@@ -255,13 +262,13 @@ export class NodeLayout {
255
262
  * walk's claims came to.
256
263
  */
257
264
  _followParent(dx, dy) {
258
- if (dx === 0 && dy === 0) return false;
259
265
  const yoga = this.yoga;
260
266
  if (!yoga || !this._placed || this.isScroller?.()) return false;
261
267
  if (yoga.hasNewLayout()) {
262
268
  yoga.markLayoutSeen();
263
269
  return false;
264
270
  }
271
+ if (dx === 0 && dy === 0) return layoutDiff.skipUnreached;
265
272
  const sink = layoutDiff.sink;
266
273
  const shift = layoutDiff.shift;
267
274
  if (
@@ -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
@@ -942,7 +980,16 @@ export const Scrollable = (Base) =>
942
980
  const ny = ev.nativeEvent?.y ?? ev.y * this.scale;
943
981
  const at = along(bar, nx, ny) - this._barGrab.offset - bar.trackStart;
944
982
  const from = bar.reversed ? bar.travel - at : at;
945
- const to = (from / bar.travel) * bar.range;
983
+ // Whole device pixels, as a wheel's default action moves (events.js).
984
+ // The pointer's share of the travel times the range is a fraction at
985
+ // almost every position, and a fractional offset costs the scroll blit,
986
+ // which can only shift by whole pixels, and draws everything in the
987
+ // pane off the pixel grid, where the X11 backend fills an edge or a
988
+ // rounded box through a coverage mask it uploads shape by shape. A
989
+ // thumb dragged through a 100,000-row table ran at 25 fps on XQuartz,
990
+ // with two thirds of the server's time in those masks, and at 52 once
991
+ // the offset was whole.
992
+ const to = Math.round((from / bar.travel) * bar.range);
946
993
  this._scrollToDevice(bar.axis === 'x' ? { x: to } : { y: to });
947
994
  }
948
995
 
@@ -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
  *