@pilates/core 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/algorithm/cache.d.ts +7 -19
  2. package/dist/algorithm/cache.d.ts.map +1 -1
  3. package/dist/algorithm/cache.js +31 -27
  4. package/dist/algorithm/cache.js.map +1 -1
  5. package/dist/algorithm/index.js +22 -14
  6. package/dist/algorithm/index.js.map +1 -1
  7. package/dist/algorithm/round.d.ts.map +1 -1
  8. package/dist/algorithm/round.js +19 -19
  9. package/dist/algorithm/round.js.map +1 -1
  10. package/dist/algorithm/spineless/classify.d.ts +47 -0
  11. package/dist/algorithm/spineless/classify.d.ts.map +1 -0
  12. package/dist/algorithm/spineless/classify.js +109 -0
  13. package/dist/algorithm/spineless/classify.js.map +1 -0
  14. package/dist/algorithm/spineless/field-id-pool.d.ts +28 -0
  15. package/dist/algorithm/spineless/field-id-pool.d.ts.map +1 -0
  16. package/dist/algorithm/spineless/field-id-pool.js +35 -0
  17. package/dist/algorithm/spineless/field-id-pool.js.map +1 -0
  18. package/dist/algorithm/spineless/flex-grammar.d.ts.map +1 -1
  19. package/dist/algorithm/spineless/flex-grammar.js +194 -58
  20. package/dist/algorithm/spineless/flex-grammar.js.map +1 -1
  21. package/dist/algorithm/spineless/grammar.d.ts +6 -0
  22. package/dist/algorithm/spineless/grammar.d.ts.map +1 -1
  23. package/dist/algorithm/spineless/grammar.js +2 -1
  24. package/dist/algorithm/spineless/grammar.js.map +1 -1
  25. package/dist/algorithm/spineless/layout.d.ts +52 -15
  26. package/dist/algorithm/spineless/layout.d.ts.map +1 -1
  27. package/dist/algorithm/spineless/layout.js +450 -312
  28. package/dist/algorithm/spineless/layout.js.map +1 -1
  29. package/dist/algorithm/spineless/order-maintenance.d.ts +9 -0
  30. package/dist/algorithm/spineless/order-maintenance.d.ts.map +1 -1
  31. package/dist/algorithm/spineless/order-maintenance.js +6 -0
  32. package/dist/algorithm/spineless/order-maintenance.js.map +1 -1
  33. package/dist/algorithm/spineless/runtime.d.ts +61 -8
  34. package/dist/algorithm/spineless/runtime.d.ts.map +1 -1
  35. package/dist/algorithm/spineless/runtime.js +201 -50
  36. package/dist/algorithm/spineless/runtime.js.map +1 -1
  37. package/dist/algorithm/spineless/style-dirty.d.ts +8 -6
  38. package/dist/algorithm/spineless/style-dirty.d.ts.map +1 -1
  39. package/dist/algorithm/spineless/style-dirty.js +10 -11
  40. package/dist/algorithm/spineless/style-dirty.js.map +1 -1
  41. package/dist/dirty-flags.d.ts +30 -0
  42. package/dist/dirty-flags.d.ts.map +1 -0
  43. package/dist/dirty-flags.js +35 -0
  44. package/dist/dirty-flags.js.map +1 -0
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +4 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/layout-pool.d.ts +49 -0
  50. package/dist/layout-pool.d.ts.map +1 -0
  51. package/dist/layout-pool.js +75 -0
  52. package/dist/layout-pool.js.map +1 -0
  53. package/dist/node.d.ts +20 -3
  54. package/dist/node.d.ts.map +1 -1
  55. package/dist/node.js +63 -42
  56. package/dist/node.js.map +1 -1
  57. package/package.json +1 -1
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { roundLayout, roundLayoutFrom } from '../round.js';
26
26
  import { buildAppendFragment, buildFlexGrammar, buildRemoveFragment, buildReorderFragment, } from './flex-grammar.js';
27
+ import { field } from './grammar.js';
27
28
  import { SpinelessRuntime } from './runtime.js';
28
29
  /** An input field's `compute` never calls `read` — guard against it. */
29
30
  const NEVER_READ = () => {
@@ -31,10 +32,10 @@ const NEVER_READ = () => {
31
32
  };
32
33
  /** Every leaf input Field (`deps: []`) the runtime currently tracks. */
33
34
  function collectInputs(grammar, runtime) {
34
- const inputs = [];
35
- for (const [field, rule] of grammar) {
36
- if (rule.deps.length === 0 && runtime.isTracked(field))
37
- inputs.push(field);
35
+ const inputs = new Set();
36
+ for (const [f, rule] of grammar) {
37
+ if (rule.deps.length === 0 && runtime.isTracked(f))
38
+ inputs.add(f);
38
39
  }
39
40
  return inputs;
40
41
  }
@@ -95,21 +96,43 @@ function nodeSig(node) {
95
96
  s.position
96
97
  .map((p) => (p === undefined ? '_' : String(p)))
97
98
  .join(','),
99
+ // Fold-predicate bits for Phase 17: each bit flips when the
100
+ // property crosses its fold boundary (default → non-default).
101
+ // Mutating a folded property must change nodeSig so the
102
+ // classifier triggers a grammar rebuild.
103
+ s.minWidth === 0 ? '_' : 'mw',
104
+ s.minHeight === 0 ? '_' : 'mh',
105
+ s.maxWidth === undefined ? '_' : 'xw',
106
+ s.maxHeight === undefined ? '_' : 'xh',
107
+ s.margin[0] === 0 ? '_' : 'm0',
108
+ s.margin[1] === 0 ? '_' : 'm1',
109
+ s.margin[2] === 0 ? '_' : 'm2',
110
+ s.margin[3] === 0 ? '_' : 'm3',
98
111
  ].join('|');
99
112
  }
100
113
  function captureSnaps(root) {
101
114
  const snaps = new Map();
102
- function visit(n) {
103
- const children = [];
104
- for (let i = 0; i < n.getChildCount(); i++)
105
- children.push(n.getChild(i));
106
- snaps.set(n, { sig: nodeSig(n), measure: n.getMeasureFunc(), children });
107
- for (const c of children)
108
- visit(c);
109
- }
110
- visit(root);
115
+ captureSnapsInto(root, snaps);
111
116
  return snaps;
112
117
  }
118
+ /** Write `node`'s subtree snaps into `target` (used by the
119
+ * fast-path graft to extend the snap map by the appended region
120
+ * without rewalking the whole tree). */
121
+ function captureSnapsInto(node, target) {
122
+ const children = [];
123
+ for (let i = 0; i < node.getChildCount(); i++)
124
+ children.push(node.getChild(i));
125
+ target.set(node, { sig: nodeSig(node), measure: node.getMeasureFunc(), children });
126
+ for (const c of children)
127
+ captureSnapsInto(c, target);
128
+ }
129
+ /** A fresh `NodeSnap` for `node` against its current children. */
130
+ function freshSnap(node) {
131
+ const children = [];
132
+ for (let i = 0; i < node.getChildCount(); i++)
133
+ children.push(node.getChild(i));
134
+ return { sig: nodeSig(node), measure: node.getMeasureFunc(), children };
135
+ }
113
136
  /** True iff `node`'s current child list still matches `snap.children`. */
114
137
  function childrenUnchanged(snap, node) {
115
138
  if (node.getChildCount() !== snap.children.length)
@@ -203,23 +226,43 @@ export class SpinelessLayout {
203
226
  this.finishWhole();
204
227
  return;
205
228
  }
206
- // Classify the dirty region: any structural change forces the
207
- // graft / rebuild paths; otherwise it is a pure value relayout.
229
+ // Classify the dirty region. The classifier collects PIVOTS
230
+ // dirty nodes whose CHILDREN LIST changed since the last layout
231
+ // and short-circuits to a full rebuild when any snapped node's
232
+ // sig / measure changed (those need a fresh grammar). A dirty
233
+ // node WITHOUT a snap is a freshly-introduced node (part of an
234
+ // appended subtree); the graft validator handles it, so the
235
+ // classifier just skips it.
236
+ //
237
+ // Dispatch:
238
+ // - sig / measure change → fullBuild
239
+ // - pivots.length === 0 → value relayout
240
+ // - pivots.length === 1 → graft / detach / reorder
241
+ // - pivots.length > 1 → fullBuild (multi-parent
242
+ // structural change; no
243
+ // fast-path handles it)
208
244
  const dirty = [];
209
245
  collectDirty(this.root, dirty);
210
246
  const snaps = this.built.snaps;
211
- let structural = false;
247
+ const pivots = [];
248
+ let needsRebuild = false;
212
249
  for (const n of dirty) {
213
250
  const snap = snaps.get(n);
214
- if (snap === undefined ||
215
- snap.sig !== nodeSig(n) ||
216
- snap.measure !== n.getMeasureFunc() ||
217
- !childrenUnchanged(snap, n)) {
218
- structural = true;
251
+ if (snap === undefined) {
252
+ // Freshly-introduced node — has no pre-layout snap. The graft
253
+ // validator below verifies the new region forms one subtree
254
+ // rooted under a single pivot; don't treat as a rebuild trigger.
255
+ continue;
256
+ }
257
+ if (snap.sig !== nodeSig(n) || snap.measure !== n.getMeasureFunc()) {
258
+ needsRebuild = true;
219
259
  break;
220
260
  }
261
+ if (!childrenUnchanged(snap, n)) {
262
+ pivots.push(n);
263
+ }
221
264
  }
222
- if (!structural) {
265
+ if (!needsRebuild && pivots.length === 0) {
223
266
  const moved = this.relayoutValues(dirty, availableWidth, availableHeight);
224
267
  this.stats.incrementalRelayouts++;
225
268
  const rs = this.built.runtime.stats;
@@ -230,48 +273,73 @@ export class SpinelessLayout {
230
273
  fieldsChanged: rs.recomputeChanged,
231
274
  movedSubtrees: moved.length,
232
275
  };
233
- this.finishIncremental(moved);
276
+ this.finishMoved(moved, []);
234
277
  clearDirtyRegion(this.root);
235
278
  return;
236
279
  }
237
- if (this.tryGraftAppend(availableWidth, availableHeight)) {
238
- this.stats.graftRelayouts++;
239
- const rs = this.built.runtime.stats;
240
- this._lastTrace = {
241
- path: 'graft',
242
- dirtyNodes: dirty.length,
243
- fieldsRecomputed: rs.recomputeVisited,
244
- fieldsChanged: rs.recomputeChanged,
245
- movedSubtrees: 0,
246
- };
247
- }
248
- else if (this.tryDetachRemove(availableWidth, availableHeight)) {
249
- this.stats.detachRelayouts++;
250
- const rs = this.built.runtime.stats;
251
- this._lastTrace = {
252
- path: 'detach',
253
- dirtyNodes: dirty.length,
254
- fieldsRecomputed: rs.recomputeVisited,
255
- fieldsChanged: rs.recomputeChanged,
256
- movedSubtrees: 0,
257
- };
258
- }
259
- else if (this.tryReorder(availableWidth, availableHeight)) {
260
- this.stats.reorderRelayouts++;
261
- const rs = this.built.runtime.stats;
262
- this._lastTrace = {
263
- path: 'reorder',
264
- dirtyNodes: dirty.length,
265
- fieldsRecomputed: rs.recomputeVisited,
266
- fieldsChanged: rs.recomputeChanged,
267
- movedSubtrees: 0,
268
- };
269
- }
270
- else {
271
- this.fullBuild(availableWidth, availableHeight);
272
- this.stats.fullBuilds++;
280
+ if (!needsRebuild && pivots.length === 1) {
281
+ const pivot = pivots[0];
282
+ const graft = this.tryGraftAppend(pivot, dirty, availableWidth, availableHeight);
283
+ if (graft !== null) {
284
+ this.stats.graftRelayouts++;
285
+ const rs = this.built.runtime.stats;
286
+ const survivorRoots = this.movedSubtreeRoots(graft.changed);
287
+ // CRITICAL: include graft.child explicitly. Its fields were
288
+ // computed via integrate() at graft time, not via recompute(),
289
+ // so they don't appear in `changed`. Without this, a simple-
290
+ // regime append (changed is empty) writes back NOTHING and
291
+ // the appended subtree ships with _layout = 0. The structural-
292
+ // differential fuzzer catches this.
293
+ const roots = [graft.child, ...survivorRoots.filter((r) => !isInSubtree(r, graft.child))];
294
+ this._lastTrace = {
295
+ path: 'graft',
296
+ dirtyNodes: dirty.length,
297
+ fieldsRecomputed: rs.recomputeVisited,
298
+ fieldsChanged: rs.recomputeChanged,
299
+ movedSubtrees: roots.length,
300
+ };
301
+ this.finishMoved(roots, []);
302
+ clearDirtyRegion(this.root);
303
+ return;
304
+ }
305
+ const detach = this.tryDetachRemove(pivot, dirty, availableWidth, availableHeight);
306
+ if (detach !== null) {
307
+ this.stats.detachRelayouts++;
308
+ const rs = this.built.runtime.stats;
309
+ const survivorRoots = this.movedSubtreeRoots(detach.changed);
310
+ this._lastTrace = {
311
+ path: 'detach',
312
+ dirtyNodes: dirty.length,
313
+ fieldsRecomputed: rs.recomputeVisited,
314
+ fieldsChanged: rs.recomputeChanged,
315
+ movedSubtrees: survivorRoots.length,
316
+ };
317
+ // Survivors may be empty for simple-regime removes; explicitly
318
+ // include detach.parent so its scroll extent is recomputed.
319
+ this.finishMoved(survivorRoots, [detach.parent]);
320
+ clearDirtyRegion(this.root);
321
+ return;
322
+ }
323
+ const reorder = this.tryReorder(pivot, dirty, availableWidth, availableHeight);
324
+ if (reorder !== null) {
325
+ this.stats.reorderRelayouts++;
326
+ const rs = this.built.runtime.stats;
327
+ const movedRoots = this.movedSubtreeRoots(reorder.changed);
328
+ this._lastTrace = {
329
+ path: 'reorder',
330
+ dirtyNodes: dirty.length,
331
+ fieldsRecomputed: rs.recomputeVisited,
332
+ fieldsChanged: rs.recomputeChanged,
333
+ movedSubtrees: movedRoots.length,
334
+ };
335
+ this.finishMoved(movedRoots, [reorder.reordered]);
336
+ clearDirtyRegion(this.root);
337
+ return;
338
+ }
273
339
  }
274
- this.finishWhole();
340
+ this.fullBuild(availableWidth, availableHeight);
341
+ this.stats.fullBuilds++;
342
+ this.finishWhole(); // Only reached by fullBuild fallback now
275
343
  }
276
344
  /** Discard any persisted state and build the grammar afresh. */
277
345
  fullBuild(availableWidth, availableHeight) {
@@ -308,89 +376,137 @@ export class SpinelessLayout {
308
376
  }
309
377
  /**
310
378
  * Fast-path a structural change that is exactly a single child
311
- * append: `buildAppendFragment` + `graft`, no whole-tree rebuild.
312
- * Returns `false` (and changes nothing) when the change is not a
313
- * clean append the fast-path covers — the caller then rebuilds.
379
+ * append at `pivot`: `buildAppendFragment` + `graft`, no whole-tree
380
+ * rebuild. Returns `null` (and changes nothing) when the change is
381
+ * not a clean append the fast-path covers — the caller then tries
382
+ * the next fast-path / rebuilds.
383
+ *
384
+ * The classifier supplies `pivot` — a dirty node whose snapshot
385
+ * exists (so sig / measure are unchanged) and whose children list
386
+ * differs from its snap's. The validator inspects ONLY pivot's
387
+ * children, not the whole tree.
314
388
  */
315
- tryGraftAppend(availableWidth, availableHeight) {
389
+ tryGraftAppend(pivot, dirty, availableWidth, availableHeight) {
316
390
  const built = this.built;
317
391
  const snaps = built.snaps;
318
- // Walk the current tree; no previously-snapshotted node may be
319
- // gone (a removal is not an append), and at least one must be new.
320
- const curNodes = new Set();
321
- (function visit(n) {
322
- curNodes.add(n);
323
- for (let i = 0; i < n.getChildCount(); i++)
324
- visit(n.getChild(i));
325
- })(this.root);
326
- for (const n of snaps.keys()) {
327
- if (!curNodes.has(n))
328
- return false;
329
- }
330
- const added = [];
331
- for (const n of curNodes) {
332
- if (!snaps.has(n))
333
- added.push(n);
334
- }
335
- if (added.length === 0)
336
- return false;
337
- // No surviving node's signature / measure may have changed —
338
- // `graft` patches only the append.
339
- for (const [n, snap] of snaps) {
340
- if (snap.sig !== nodeSig(n) || snap.measure !== n.getMeasureFunc())
341
- return false;
342
- }
343
- // The added nodes must form exactly one subtree — its root is the
344
- // unique added node whose parent is not itself added.
345
- const addedSet = new Set(added);
346
- let child = null;
347
- for (const n of added) {
348
- const p = n.getParent();
349
- if (p === null || !addedSet.has(p)) {
350
- if (child !== null)
351
- return false; // two separate appends
352
- child = n;
392
+ const snap = snaps.get(pivot);
393
+ // Pivot's live children must be its snapped children with exactly
394
+ // ONE inserted child (anywhere — last for simple regime, mid-list
395
+ // for non-simple). Walk pivot's children positionally —
396
+ // O(pivot's children), not O(tree).
397
+ const live = pivot.getChildCount();
398
+ const snapped = snap.children.length;
399
+ if (live !== snapped + 1)
400
+ return null;
401
+ // Find the insertion index: the first position where pivot's live
402
+ // child diverges from snap. After the new child, the tail must
403
+ // match the rest of the snap.
404
+ let insertAt = snapped; // default: appended at the end
405
+ for (let i = 0; i < snapped; i++) {
406
+ if (pivot.getChild(i) !== snap.children[i]) {
407
+ insertAt = i;
408
+ break;
353
409
  }
354
410
  }
355
- if (child === null)
356
- return false;
357
- const parent = child.getParent();
358
- if (parent === null)
359
- return false;
411
+ // Verify the tail past the insertion matches.
412
+ for (let i = insertAt; i < snapped; i++) {
413
+ if (pivot.getChild(i + 1) !== snap.children[i])
414
+ return null;
415
+ }
416
+ const child = pivot.getChild(insertAt);
360
417
  // A change inside a `display: 'none'` subtree: the hidden region
361
418
  // has no grammar fields to graft onto. A node with no entry in
362
419
  // `fields` is hidden (or under a hidden ancestor) — fall back to
363
420
  // a rebuild, which correctly skips the whole hidden subtree.
364
- if (!built.fields.has(parent))
365
- return false;
366
- const fragment = buildAppendFragment(built.output, this.root, parent, child, built.available);
421
+ if (!built.fields.has(pivot))
422
+ return null;
423
+ // Every other dirty-with-no-snap node must be a descendant of
424
+ // `child` (i.e. the appended subtree). The classifier already
425
+ // collected them; if any escaped this subtree, it would be an
426
+ // independent append elsewhere — handled by the multi-pivot
427
+ // fullBuild fallback, but a single-pivot append must be tight.
428
+ //
429
+ // Verifying this requires walking just the appended subtree:
430
+ // O(added subtree).
431
+ const subtreeMembers = new Set();
432
+ {
433
+ const stack = [child];
434
+ while (stack.length > 0) {
435
+ const n = stack.pop();
436
+ subtreeMembers.add(n);
437
+ for (let i = 0; i < n.getChildCount(); i++)
438
+ stack.push(n.getChild(i));
439
+ }
440
+ }
441
+ const fragment = buildAppendFragment(built.output, this.root, pivot, child, built.available);
367
442
  if (fragment === null)
368
- return false;
443
+ return null;
369
444
  built.runtime.graft(fragment.additions, fragment.newRoots);
370
- for (const [field, rule] of fragment.rebinds)
371
- built.runtime.rebindRule(field, rule);
445
+ for (const [rf, rule] of fragment.rebinds)
446
+ built.runtime.rebindRule(rf, rule);
372
447
  built.output = fragment.next;
373
- built.inputs = collectInputs(fragment.next.grammar, built.runtime);
374
- built.snaps = captureSnaps(this.root);
375
- const idx = indexFields(fragment.next);
376
- built.fields = idx.fields;
377
- built.owner = idx.owner;
378
- // Pick up any value mutations in the same gap, then recompute
379
- // (covering the grafted / rebound fields too).
380
- this.applyAvailable(availableWidth, availableHeight);
381
- for (const field of built.inputs) {
382
- if (built.output.grammar.get(field).compute(NEVER_READ) !== built.runtime.evaluate(field)) {
383
- built.runtime.markDirty(field);
448
+ // Incremental bookkeeping (Part C):
449
+ //
450
+ // `built.snaps` update pivot's snap (children list changed) and
451
+ // add a fresh snap for every node in the appended subtree.
452
+ built.snaps.set(pivot, freshSnap(pivot));
453
+ captureSnapsInto(child, built.snaps);
454
+ // `built.fields` / `built.owner` every new layout field belongs
455
+ // to a node in the appended subtree. Walk the subtree and gather
456
+ // each node's four layout fields from `fragment.additions`. A
457
+ // `display: 'none'` descendant has no fields (it isn't in
458
+ // additions for its layout-field keys); skip it.
459
+ for (const n of subtreeMembers) {
460
+ if (n.style.display === 'none')
461
+ continue;
462
+ const w = field(n, 'width');
463
+ const h = field(n, 'height');
464
+ const l = field(n, 'left');
465
+ const t = field(n, 'top');
466
+ if (!fragment.next.grammar.has(w))
467
+ continue;
468
+ built.fields.set(n, { width: w, height: h, left: l, top: t });
469
+ built.owner.set(w, n);
470
+ built.owner.set(h, n);
471
+ built.owner.set(l, n);
472
+ built.owner.set(t, n);
473
+ }
474
+ // `built.inputs` — add new leaf input fields from additions. For
475
+ // simple regime nothing existing changes tracking, so this is
476
+ // additive. For non-simple regime, existing inputs may have just
477
+ // become tracked (e.g. the previous last child's main-END
478
+ // margin); the runtime's tracking is post-graft, so filter the
479
+ // OLD list to only tracked + add new tracked leaves.
480
+ if (fragment.rebinds.length === 0) {
481
+ // Simple regime: no existing input changed tracking — just
482
+ // append the new tracked leaves.
483
+ for (const [f, rule] of fragment.additions) {
484
+ if (rule.deps.length === 0 && built.runtime.isTracked(f)) {
485
+ built.inputs.add(f);
486
+ }
384
487
  }
385
488
  }
386
- built.runtime.recompute();
387
- return true;
489
+ else {
490
+ // Non-simple regime: existing inputs may have gained or lost
491
+ // tracking. The cheapest correct update is a full recompute
492
+ // (same cost as the fragment builder's O(tree) rebuild).
493
+ built.inputs = collectInputs(fragment.next.grammar, built.runtime);
494
+ }
495
+ // Pick up any value mutations in the same gap, then recompute
496
+ // (covering the grafted / rebound fields too). Iterate ONLY the
497
+ // dirty nodes' inputs — every value mutation marks its node
498
+ // dirty, so we don't need to scan the whole input set.
499
+ this.applyAvailable(availableWidth, availableHeight);
500
+ this.markDriftedInputs(dirty);
501
+ const changed = built.runtime.recompute();
502
+ return { child, changed };
388
503
  }
389
504
  /**
390
505
  * Fast-path a structural change that is exactly a single subtree
391
- * removal: `buildRemoveFragment` + `rebindRule` / `detach`, no
392
- * whole-tree rebuild. Returns `false` (changing nothing) when the
393
- * change is not a clean removal — the caller then rebuilds.
506
+ * removal at `pivot`: `buildRemoveFragment` + `rebindRule` /
507
+ * `detach`, no whole-tree rebuild. Returns `null` (changing nothing)
508
+ * when the change is not a clean removal — the caller then tries
509
+ * the next fast-path / rebuilds.
394
510
  *
395
511
  * `buildRemoveFragment` must see the removed `child` still attached
396
512
  * (its regime check reads the parent's live child list and it walks
@@ -398,202 +514,207 @@ export class SpinelessLayout {
398
514
  * runs the caller has already detached it — so the removed subtree
399
515
  * is briefly re-inserted at its old index for the fragment build,
400
516
  * then detached again.
517
+ *
518
+ * The classifier supplies `pivot` — the parent whose children list
519
+ * differs from its snap's. The validator inspects ONLY pivot's
520
+ * children, not the whole tree.
401
521
  */
402
- tryDetachRemove(availableWidth, availableHeight) {
522
+ tryDetachRemove(pivot, dirty, availableWidth, availableHeight) {
403
523
  const built = this.built;
404
524
  const snaps = built.snaps;
405
- // Walk the current tree. No previously-snapshotted node may be new
406
- // (an addition is not a removal); at least one must be gone.
407
- const curNodes = new Set();
408
- (function visit(n) {
409
- curNodes.add(n);
410
- for (let i = 0; i < n.getChildCount(); i++)
411
- visit(n.getChild(i));
412
- })(this.root);
413
- for (const n of curNodes) {
414
- if (!snaps.has(n))
415
- return false;
416
- }
417
- const removed = [];
418
- for (const n of snaps.keys()) {
419
- if (!curNodes.has(n))
420
- removed.push(n);
421
- }
422
- if (removed.length === 0)
423
- return false;
424
- // No surviving node's signature / measure may have changed —
425
- // `detach` patches only the removal.
426
- for (const [n, snap] of snaps) {
427
- if (!curNodes.has(n))
428
- continue;
429
- if (snap.sig !== nodeSig(n) || snap.measure !== n.getMeasureFunc())
430
- return false;
431
- }
432
- // The snapped child -> parent map — the live tree no longer holds
433
- // the removed nodes' edges.
434
- const snapParent = new Map();
435
- for (const [n, snap] of snaps) {
436
- for (const c of snap.children)
437
- snapParent.set(c, n);
438
- }
439
- // The removed nodes must form exactly one subtree — its root is
440
- // the unique removed node whose snapped parent is not itself
441
- // removed; that parent must survive.
442
- const removedSet = new Set(removed);
443
- let child = null;
444
- for (const n of removed) {
445
- const p = snapParent.get(n);
446
- if (p === undefined || !removedSet.has(p)) {
447
- if (child !== null)
448
- return false; // two separate removals
449
- child = n;
450
- }
451
- }
452
- if (child === null)
453
- return false;
454
- const parent = snapParent.get(child);
455
- if (parent === undefined || !curNodes.has(parent))
456
- return false;
525
+ const snap = snaps.get(pivot);
526
+ const snapChildren = snap.children;
527
+ // Pivot's live children must be its snapped children with exactly
528
+ // one contiguous removal. Walk pivot's children positionally —
529
+ // O(pivot's children), not O(tree).
530
+ const liveCount = pivot.getChildCount();
531
+ const snappedCount = snapChildren.length;
532
+ if (liveCount >= snappedCount)
533
+ return null;
534
+ // Find the index where they diverge; this is the start of the
535
+ // removed run.
536
+ let removedStart = 0;
537
+ while (removedStart < liveCount &&
538
+ pivot.getChild(removedStart) === snapChildren[removedStart]) {
539
+ removedStart++;
540
+ }
541
+ const removedCount = snappedCount - liveCount;
542
+ // Verify the snapped tail past the removed run still matches the
543
+ // live tail.
544
+ for (let i = removedStart; i < liveCount; i++) {
545
+ if (pivot.getChild(i) !== snapChildren[i + removedCount])
546
+ return null;
547
+ }
548
+ // Single-child removal: simple-regime requirement. (A wider
549
+ // contiguous removal would still be a valid fragment for
550
+ // non-simple regime, but the fragment builder is single-`child`
551
+ // only, so split-merge runs fall through to fullBuild.)
552
+ if (removedCount !== 1)
553
+ return null;
554
+ const child = snapChildren[removedStart];
457
555
  // A removal inside a `display: 'none'` subtree: the hidden region
458
556
  // has no fields to `detach`. A node absent from `fields` is hidden
459
557
  // (or under a hidden ancestor) — rebuild instead.
460
- if (!built.fields.has(parent))
461
- return false;
462
- // `removed` must be exactly `child`'s snapped subtree.
463
- let subtreeCount = 0;
464
- (function count(n) {
465
- subtreeCount++;
466
- for (const c of snaps.get(n).children)
467
- count(c);
468
- })(child);
469
- if (subtreeCount !== removed.length)
470
- return false;
471
- // `parent`'s current children must be its snapped children with
472
- // `child` spliced out — so re-inserting `child` at `idx` restores
473
- // the exact pre-removal tree for `buildRemoveFragment`.
474
- const snapChildren = snaps.get(parent).children;
475
- const idx = snapChildren.indexOf(child);
476
- if (idx === -1 || parent.getChildCount() !== snapChildren.length - 1)
477
- return false;
478
- for (let i = 0, j = 0; i < snapChildren.length; i++) {
479
- if (snapChildren[i] === child)
480
- continue;
481
- if (parent.getChild(j) !== snapChildren[i])
482
- return false;
483
- j++;
558
+ if (!built.fields.has(pivot))
559
+ return null;
560
+ // The removed subtree's nodes must still be reachable from
561
+ // `child` through SNAP edges (the live tree no longer has them).
562
+ // Collect them via DFS over snaps.
563
+ const removedNodes = new Set();
564
+ {
565
+ const stack = [child];
566
+ while (stack.length > 0) {
567
+ const n = stack.pop();
568
+ removedNodes.add(n);
569
+ const cs = snaps.get(n);
570
+ if (cs !== undefined)
571
+ for (const c of cs.children)
572
+ stack.push(c);
573
+ }
484
574
  }
485
575
  // Re-attach `child` for the fragment build, then detach it again.
486
- parent.insertChild(child, idx);
487
- const fragment = buildRemoveFragment(built.output, this.root, parent, child, built.available);
488
- parent.removeChild(child);
576
+ pivot.insertChild(child, removedStart);
577
+ const fragment = buildRemoveFragment(built.output, this.root, pivot, child, built.available);
578
+ pivot.removeChild(child);
489
579
  if (fragment === null)
490
- return false;
580
+ return null;
491
581
  // Apply: rebind survivors FIRST (so they stop reading the removed
492
582
  // fields), then `detach`, then adopt the next grammar.
493
- for (const [field, rule] of fragment.rebinds)
494
- built.runtime.rebindRule(field, rule);
495
- built.runtime.detach(fragment.removed);
583
+ for (const [f, rule] of fragment.rebinds)
584
+ built.runtime.rebindRule(f, rule);
585
+ const dropped = built.runtime.detach(fragment.removed);
496
586
  built.output = fragment.next;
497
- built.inputs = collectInputs(fragment.next.grammar, built.runtime);
498
- built.snaps = captureSnaps(this.root);
499
- const ix = indexFields(fragment.next);
500
- built.fields = ix.fields;
501
- built.owner = ix.owner;
502
- // Pick up any value mutations in the same batch, then recompute.
503
- this.applyAvailable(availableWidth, availableHeight);
504
- for (const field of built.inputs) {
505
- if (built.output.grammar.get(field).compute(NEVER_READ) !== built.runtime.evaluate(field)) {
506
- built.runtime.markDirty(field);
587
+ // Incremental bookkeeping (Part C):
588
+ //
589
+ // `built.snaps` update pivot's snap (children list changed) and
590
+ // drop the removed subtree's snaps.
591
+ built.snaps.set(pivot, freshSnap(pivot));
592
+ for (const n of removedNodes)
593
+ built.snaps.delete(n);
594
+ // `built.fields` / `built.owner` — drop the removed subtree's
595
+ // entries. Each removed node may have had `display: 'none'` (no
596
+ // entry) or normal layout fields — `built.fields.delete` on a
597
+ // missing key is harmless; `built.owner.delete` likewise.
598
+ for (const n of removedNodes) {
599
+ const lf = built.fields.get(n);
600
+ if (lf !== undefined) {
601
+ built.owner.delete(lf.width);
602
+ built.owner.delete(lf.height);
603
+ built.owner.delete(lf.left);
604
+ built.owner.delete(lf.top);
605
+ built.fields.delete(n);
507
606
  }
508
607
  }
509
- built.runtime.recompute();
510
- return true;
608
+ // `built.inputs` — remove every field detach dropped (the removed
609
+ // subtree's inputs + any survivor orphan-cleaned by detach).
610
+ // O(|dropped|) — detach's returned set is complete by construction:
611
+ // its `drop` closure is the single choke point for ALL removals,
612
+ // including orphan-cleaned surviving deps (e.g. the previous last
613
+ // child's now-unread main-end margin). Set.delete is a no-op for
614
+ // absent keys, so iterating all of `dropped` (which includes
615
+ // non-input layout fields) correctly removes only the dropped
616
+ // leaf inputs from `built.inputs`.
617
+ for (const f of dropped)
618
+ built.inputs.delete(f);
619
+ // Pick up any value mutations in the same batch, then recompute.
620
+ // Iterate ONLY the dirty nodes' inputs — every value mutation
621
+ // marks its node dirty.
622
+ this.applyAvailable(availableWidth, availableHeight);
623
+ this.markDriftedInputs(dirty);
624
+ const changed = built.runtime.recompute();
625
+ return { parent: pivot, changed };
511
626
  }
512
627
  /**
513
- * Fast-path a structural change that is exactly one parent's
514
- * children being reordered (a permutation — no node added or
515
- * removed): `buildReorderFragment` + `rebindRule`, no whole-tree
516
- * rebuild. Returns `false` (changing nothing) when the change is
517
- * not a clean single-parent reorder — the caller then rebuilds.
628
+ * Fast-path a structural change that is exactly `pivot`'s children
629
+ * being reordered (a permutation — no node added or removed):
630
+ * `buildReorderFragment` + `rebindRule`, no whole-tree rebuild.
631
+ * Returns `null` (changing nothing) when the change is not a clean
632
+ * single-parent reorder — the caller then rebuilds.
633
+ *
634
+ * The classifier supplies `pivot` — the single node whose children
635
+ * list differs from its snap's. The validator inspects ONLY pivot's
636
+ * children, not the whole tree.
518
637
  */
519
- tryReorder(availableWidth, availableHeight) {
638
+ tryReorder(pivot, dirty, availableWidth, availableHeight) {
520
639
  const built = this.built;
521
- const snaps = built.snaps;
522
- // The node set must be unchanged no addition, no removal.
523
- const curNodes = new Set();
524
- (function visit(n) {
525
- curNodes.add(n);
526
- for (let i = 0; i < n.getChildCount(); i++)
527
- visit(n.getChild(i));
528
- })(this.root);
529
- if (curNodes.size !== snaps.size)
530
- return false;
531
- for (const n of curNodes) {
532
- if (!snaps.has(n))
533
- return false;
534
- }
535
- // Every node's signature / measure must be unchanged, and exactly
536
- // one node's child ORDER may differ — the reordered parent.
537
- let reordered = null;
538
- for (const [n, snap] of snaps) {
539
- if (snap.sig !== nodeSig(n) || snap.measure !== n.getMeasureFunc())
540
- return false;
541
- if (!childrenUnchanged(snap, n)) {
542
- if (reordered !== null)
543
- return false; // two changed parents — not one reorder
544
- reordered = n;
545
- }
546
- }
547
- if (reordered === null)
548
- return false;
549
- // `reordered`'s children must be a permutation of the snapped
550
- // set (same count, same members) — otherwise it is an add/remove.
551
- const before = snaps.get(reordered).children;
552
- if (before.length !== reordered.getChildCount())
553
- return false;
640
+ const snap = built.snaps.get(pivot);
641
+ // Pivot's children must be a permutation of the snapped set
642
+ // (same count, same members). Otherwise it is an add/remove —
643
+ // tryGraftAppend / tryDetachRemove would have caught it.
644
+ const before = snap.children;
645
+ if (before.length !== pivot.getChildCount())
646
+ return null;
554
647
  const beforeSet = new Set(before);
555
- for (let i = 0; i < reordered.getChildCount(); i++) {
556
- if (!beforeSet.has(reordered.getChild(i)))
557
- return false;
648
+ for (let i = 0; i < pivot.getChildCount(); i++) {
649
+ if (!beforeSet.has(pivot.getChild(i)))
650
+ return null;
558
651
  }
559
652
  // A reorder inside a `display: 'none'` subtree touches no laid-out
560
653
  // node — the hidden region has no fields. Rebuild instead.
561
- if (!built.fields.has(reordered))
562
- return false;
563
- const fragment = buildReorderFragment(built.output, this.root, reordered, built.available);
654
+ if (!built.fields.has(pivot))
655
+ return null;
656
+ const fragment = buildReorderFragment(built.output, this.root, pivot, built.available);
564
657
  // Order: integrate the newly-read inputs, rebind the rewritten
565
658
  // rules (their new deps are now all present), then detach the
566
659
  // inputs no rebound rule reads any more.
567
660
  built.runtime.graft(fragment.additions, fragment.newRoots);
568
- for (const [field, rule] of fragment.rebinds)
569
- built.runtime.rebindRule(field, rule);
661
+ for (const [f, rule] of fragment.rebinds)
662
+ built.runtime.rebindRule(f, rule);
570
663
  if (fragment.removed.length > 0)
571
664
  built.runtime.detach(fragment.removed);
572
665
  built.output = fragment.next;
573
- built.inputs = collectInputs(fragment.next.grammar, built.runtime);
574
- built.snaps = captureSnaps(this.root);
575
- const ix = indexFields(fragment.next);
576
- built.fields = ix.fields;
577
- built.owner = ix.owner;
578
- // Pick up any value mutations in the same batch, then recompute.
579
- this.applyAvailable(availableWidth, availableHeight);
580
- for (const field of built.inputs) {
581
- if (built.output.grammar.get(field).compute(NEVER_READ) !== built.runtime.evaluate(field)) {
582
- built.runtime.markDirty(field);
666
+ // Incremental bookkeeping (Part C):
667
+ //
668
+ // `built.snaps` pivot's children list changed; everything else
669
+ // stayed put. Update just pivot's snap.
670
+ built.snaps.set(pivot, freshSnap(pivot));
671
+ // `built.fields` / `built.owner` the same nodes own the same
672
+ // four layout fields they did before; no entry adds or drops.
673
+ // (Field identity is stable across grammar rebuilds.)
674
+ //
675
+ // `built.inputs` — a reorder can newly-track or newly-untrack
676
+ // sibling main-end margins (`additions` / `removed`). Update
677
+ // incrementally.
678
+ if (fragment.removed.length > 0) {
679
+ for (const f of fragment.removed)
680
+ built.inputs.delete(f);
681
+ }
682
+ for (const [f, rule] of fragment.additions) {
683
+ if (rule.deps.length === 0 && built.runtime.isTracked(f)) {
684
+ built.inputs.add(f);
583
685
  }
584
686
  }
585
- built.runtime.recompute();
586
- return true;
687
+ // Pick up any value mutations in the same batch, then recompute.
688
+ // Iterate ONLY the dirty nodes' inputs — every value mutation
689
+ // marks its node dirty.
690
+ this.applyAvailable(availableWidth, availableHeight);
691
+ this.markDriftedInputs(dirty);
692
+ const changed = built.runtime.recompute();
693
+ return { reordered: pivot, changed };
587
694
  }
588
695
  /**
589
696
  * Value relayout: re-`markDirty` only the input Fields of the dirty
590
697
  * nodes (plus the root `available:*` inputs) whose value drifted,
591
698
  * then `recompute()`. Returns the maximal subtree roots whose
592
- * layout moved — for `finishIncremental` to write back.
699
+ * layout moved — for `finishMoved` to write back.
593
700
  */
594
701
  relayoutValues(dirty, availableWidth, availableHeight) {
595
- const built = this.built;
596
702
  this.applyAvailable(availableWidth, availableHeight);
703
+ this.markDriftedInputs(dirty);
704
+ return this.movedSubtreeRoots(this.built.runtime.recompute());
705
+ }
706
+ /**
707
+ * Re-`markDirty` only the input Fields of the dirty nodes (plus the
708
+ * root `available:*` inputs) whose live value drifted from the
709
+ * runtime's stored value. Shared by every relayout path — the value
710
+ * relayout and all three structural fast-paths.
711
+ *
712
+ * Iterates O(dirty inputs), not O(built.inputs) — every value
713
+ * mutation marks its owning node dirty, so non-dirty nodes can't
714
+ * have drifted inputs.
715
+ */
716
+ markDriftedInputs(dirty) {
717
+ const built = this.built;
597
718
  const fields = [];
598
719
  if (built.output.availableInputs.width !== undefined) {
599
720
  fields.push(built.output.availableInputs.width);
@@ -604,27 +725,48 @@ export class SpinelessLayout {
604
725
  for (const n of dirty)
605
726
  inputFieldsOf(built.output.styleInputs.get(n), fields);
606
727
  const { runtime, output } = built;
607
- for (const field of fields) {
728
+ for (const f of fields) {
608
729
  // `styleInputs` can hold an input Field no rule reads (e.g. a
609
730
  // flex-start container's main-END padding) — untracked, and a
610
731
  // change to it cannot move any layout field. Skip it.
611
- if (!runtime.isTracked(field))
732
+ if (!runtime.isTracked(f))
612
733
  continue;
613
- const live = output.grammar.get(field).compute(NEVER_READ);
614
- if (live !== runtime.evaluate(field))
615
- runtime.markDirty(field);
734
+ const live = output.grammar.get(f).compute(NEVER_READ);
735
+ if (live !== runtime.evaluate(f))
736
+ runtime.markDirty(f);
737
+ }
738
+ }
739
+ /** Push new `available` values into the holder the grammar closes over. */
740
+ applyAvailable(availableWidth, availableHeight) {
741
+ const a = this.built.available;
742
+ if (availableWidth !== undefined)
743
+ a.width = availableWidth;
744
+ if (availableHeight !== undefined)
745
+ a.height = availableHeight;
746
+ }
747
+ /** Write-back + round + scroll the whole tree (after a build / graft). */
748
+ finishWhole() {
749
+ const { runtime, output } = this.built;
750
+ for (const f of output.allFields) {
751
+ writeNode(f.node, runtime, { width: f.width, height: f.height, left: f.left, top: f.top });
616
752
  }
617
- // The changed layout Fields name the nodes whose box moved.
618
- const changed = runtime.recompute();
753
+ roundLayout(this.root);
754
+ recordScrollSizes(this.root);
755
+ clearDirtyDeep(this.root);
756
+ }
757
+ /**
758
+ * Reduce a set of changed Fields to the maximal moved-subtree roots.
759
+ * Used by the structural fast-paths to determine which subtrees need
760
+ * write-back after an incremental recompute.
761
+ */
762
+ movedSubtreeRoots(changed) {
763
+ const built = this.built;
619
764
  const moved = new Set();
620
765
  for (const f of changed) {
621
766
  const n = built.owner.get(f);
622
767
  if (n !== undefined)
623
768
  moved.add(n);
624
769
  }
625
- // Keep only the maximal moved subtree roots — a moved node with
626
- // no moved ancestor. Re-rounding such a root covers its whole
627
- // (shifted) subtree.
628
770
  const roots = [];
629
771
  for (const n of moved) {
630
772
  let maximal = true;
@@ -639,31 +781,17 @@ export class SpinelessLayout {
639
781
  }
640
782
  return roots;
641
783
  }
642
- /** Push new `available` values into the holder the grammar closes over. */
643
- applyAvailable(availableWidth, availableHeight) {
644
- const a = this.built.available;
645
- if (availableWidth !== undefined)
646
- a.width = availableWidth;
647
- if (availableHeight !== undefined)
648
- a.height = availableHeight;
649
- }
650
- /** Write-back + round + scroll the whole tree (after a build / graft). */
651
- finishWhole() {
652
- const { runtime, output } = this.built;
653
- for (const f of output.allFields) {
654
- writeNode(f.node, runtime, { width: f.width, height: f.height, left: f.left, top: f.top });
655
- }
656
- roundLayout(this.root);
657
- recordScrollSizes(this.root);
658
- clearDirtyDeep(this.root);
659
- }
660
784
  /**
661
785
  * Write-back + round + scroll, scoped to the subtrees that moved.
662
786
  * A moved subtree's parent did not move, so its rounding is stable
663
787
  * and the subtree can be re-rounded in isolation; only that
664
788
  * parent's own scroll extent then needs a recompute.
789
+ *
790
+ * `extraScrollParents` are additional nodes whose scroll extents
791
+ * must be recomputed — used by structural fast-paths to include
792
+ * the surviving parent of a removed or reordered subtree.
665
793
  */
666
- finishIncremental(roots) {
794
+ finishMoved(roots, extraScrollParents) {
667
795
  const { runtime, fields } = this.built;
668
796
  for (const root of roots) {
669
797
  // Write the float layout for the whole moved subtree, so the
@@ -694,6 +822,8 @@ export class SpinelessLayout {
694
822
  if (p !== null)
695
823
  scrollParents.add(p);
696
824
  }
825
+ for (const p of extraScrollParents)
826
+ scrollParents.add(p);
697
827
  for (const p of scrollParents)
698
828
  recomputeScroll(p);
699
829
  }
@@ -752,4 +882,12 @@ function recomputeScroll(node) {
752
882
  node._layout.scrollWidth = Math.max(node._layout.width, contentRight);
753
883
  node._layout.scrollHeight = Math.max(node._layout.height, contentBottom);
754
884
  }
885
+ /** True iff `candidate` is `root` or a descendant of `root`. */
886
+ function isInSubtree(candidate, root) {
887
+ for (let n = candidate; n !== null; n = n.getParent()) {
888
+ if (n === root)
889
+ return true;
890
+ }
891
+ return false;
892
+ }
755
893
  //# sourceMappingURL=layout.js.map