@solidjs/signals 2.0.0-rc.10 → 2.0.0-rc.13

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.
@@ -208,6 +208,8 @@ r = -1) {
208
208
  // committed is the held view `hv[key]`, staged is `v[key]`.
209
209
  const l = heldFoldTransition(e);
210
210
  let s = heldAdoptionTransition(e);
211
+ // A key the adoption left unchanged is not born holding (#3706).
212
+ if (s !== null && !heldKey(e, t)) s = null;
211
213
  if (s !== null) n = e.hv[t]; else if ((s = l) !== null) n = e.v[t];
212
214
  // Create-floor diet: slotSignal bakes the whole node into one literal —
213
215
  // no options object, no equals/unobserved closures, no NodeExtension,
@@ -315,6 +317,33 @@ r = -1) {
315
317
  return true;
316
318
  }
317
319
 
320
+ // #3706: the keys an adoption under a live hold changed against the held
321
+ // view — the adoption twin of `wk` (#3688), keyed on the target. adoptPB
322
+ // records `[adopted object]`; the keys are diffed against IT, once, on the
323
+ // first held read (its children are re-pointed by then), and replace the
324
+ // entry. Never against the live backing — a mainline write during the hold
325
+ // replaces that, and its key is not the adoption's. WK_ALL holds the whole
326
+ // container.
327
+ const heldKeys = new WeakMap;
328
+
329
+ function heldKey(e, t) {
330
+ let n = heldKeys.get(e);
331
+ if (Array.isArray(n)) heldKeys.set(e, n = adoptionChangedKeys(e, n[0]));
332
+ return n === WK_ALL || n.has(t);
333
+ }
334
+
335
+ function adoptionChangedKeys(e, t) {
336
+ const n = e.hv;
337
+ // A chained backing, an optimistic family, a chained held view, a swapped
338
+ // or non-plain prototype (inherited accessors read through `this`): whole.
339
+ if (t[$TARGET] !== undefined || e.fam?.opt === true || n[$TARGET] !== undefined || Object.getPrototypeOf(n) !== Object.getPrototypeOf(t) || !plainProto(t)) return WK_ALL;
340
+ const r = new Set;
341
+ // Accessors are never invoked: they and a flipped enumerability are changes.
342
+ for (const i of Reflect.ownKeys(n)) if (!hasOwn.call(t, i) || isOwnAccessor(n, i) || isOwnAccessor(t, i) || propertyIsEnumerable.call(n, i) !== propertyIsEnumerable.call(t, i) || !(isEqual(n[i], t[i]) || sameLogicalSlot(e, n[i], t[i]))) r.add(i);
343
+ for (const e of Reflect.ownKeys(t)) if (!hasOwn.call(n, e)) r.add(e);
344
+ return r;
345
+ }
346
+
318
347
  function stageHeldKey(e, t, n) {
319
348
  if (slotNodeEquals.call(e, e.ce, t)) return;
320
349
  e._e = t;
@@ -508,6 +537,23 @@ let hookInstalled = false;
508
537
  return r;
509
538
  }
510
539
 
540
+ /** Shallow copy of a WIDE plain-data (sc 2) backing for the overlay rebuild
541
+ * (#3689). Own keys land on a null-prototype object — V8 creates those in
542
+ * dictionary mode, so the copy is O(keys) hash inserts — and the prototype
543
+ * is attached after. A spread builds a fast-mode object one map transition
544
+ * per key; from a source in dictionary mode (a backing that took deletes)
545
+ * whose key order differs from the last copy's, every transition is a fresh
546
+ * map with an O(keys) descriptor copy: measured 270 µs vs 28 µs at 400 keys,
547
+ * 1.3 ms vs 75 µs at 1000. Narrow, stable-shaped backings keep the spread
548
+ * (cloneRaw): there the clone IC copies the property store at once and is
549
+ * 3–8× cheaper than any per-key loop. */ function wideClone(e, t) {
550
+ const n = Object.create(null);
551
+ for (const t of Reflect.ownKeys(e)) n[t] = e[t];
552
+ Object.setPrototypeOf(n, Object.prototype);
553
+ n[$OWNER] = t;
554
+ return n;
555
+ }
556
+
511
557
  /** Copy own `key` from `from` onto `to`. A plain data slot (enumerable,
512
558
  * writable, configurable, no accessor) is a bare assignment — the common case
513
559
  * and the cheap one; anything else goes through defineProperty so accessors
@@ -544,21 +590,67 @@ let hookInstalled = false;
544
590
  * backing into a prototype, and every later flatten writes into that
545
591
  * prototype); above it the clone's O(keys) copy dominates (#3044). */ const OVERLAY_MIN_KEYS = 32;
546
592
 
593
+ /** Overlay commit choice (#3689): flatten in place, or rebuild the backing
594
+ * (materializePB, then the clone path's swap)? Measured on V8 (Node 26): a
595
+ * committed backing that has served as an overlay prototype is kept as a
596
+ * FAST-MODE prototype object up to V8's descriptor limit (1020 own
597
+ * properties), and there every in-place DELETE is O(keys) — normalize to
598
+ * dictionary plus re-optimize as a prototype, ~0.08 µs × keys — and every
599
+ * in-place ADD is a map transition copying the descriptors, ~0.006 µs ×
600
+ * keys. Detaching the overlay first does not help: prototype-ness is a
601
+ * property of the object's map. One rebuild (wideClone, deletes, writes) is
602
+ * ~0.07–0.09 µs × keys — the price of ONE in-place delete. So a fold that
603
+ * deleted anything, or added more than ~16 keys, commits cheaper rebuilt (a
604
+ * 400-key record churning 100 keys per commit, 10,000 membership readers:
605
+ * 4.7 ms → 0.22 ms per step); a pure rewrite of existing keys — the
606
+ * overlay's home case — stays in place at O(written).
607
+ * Past the descriptor limit V8 keeps the backing in dictionary mode where
608
+ * every in-place op is O(1), and a rebuild would be a pure O(keys) loss on
609
+ * exactly the growing-record pattern the overlay exists for (#3044) — the
610
+ * count gate keeps those in place. The gate reads `kc`, which the set trap
611
+ * counts up for keys new to the container and the delete commit counts down. */ const OVERLAY_REBUILD_MAX_KEYS = 1024;
612
+
613
+ const OVERLAY_REBUILD_MIN_ADDS = 16;
614
+
615
+ function overlayRebuilds(e, t) {
616
+ if (e.kc > OVERLAY_REBUILD_MAX_KEYS) return false;
617
+ if (e.del !== null && e.del.size !== 0) return true;
618
+ // Every trap write records its key, so the written-key count bounds the
619
+ // adds — the common few-key fold (#3044's growing record) answers here
620
+ // without enumerating the overlay.
621
+ const n = e.wk;
622
+ if (n !== null && n !== WK_ALL && n.size <= OVERLAY_REBUILD_MIN_ADDS) return false;
623
+ const r = e.v;
624
+ let i = 0;
625
+ for (const e of Reflect.ownKeys(t)) if (!hasOwn.call(r, e) && ++i > OVERLAY_REBUILD_MIN_ADDS) return true;
626
+ return false;
627
+ }
628
+
547
629
  /** Downgrade a prototype-overlay pending backing to the clone path: builds
548
630
  * the real container (committed + overlay writes − deletes) that fold will
549
631
  * SWAP in as the committed backing, exactly as if the draft had started on
550
632
  * the clone path. Consumers that need a complete container (reconcile's
551
- * diff walks, drafts escaping into other storage) call this. */ function materializePB(e) {
552
- if (!e.ovl) return;
633
+ * diff walks, drafts escaping into other storage) call this, and so does the
634
+ * commit itself when the fold changed the key set (overlayRebuilds). Returns
635
+ * the pending backing (the clone, or the pb as-is when not an overlay). */ function materializePB(e) {
553
636
  const t = e.pb;
554
- const n = cloneRaw(e.v, e);
555
- for (const e of Reflect.ownKeys(t)) copyOwn(n, t, e);
556
- if (e.del !== null) {
637
+ if (!e.ovl) return t;
638
+ const n = e.sc === 2 ? wideClone(e.v, e) : cloneRaw(e.v, e);
639
+ // Deletes before writes (#3689): on a fast-mode clone (the descriptor
640
+ // path) the first delete normalizes it once, and the writes then land as
641
+ // O(1) dictionary adds instead of O(keys) map transitions each. Order is
642
+ // free semantically — a rewritten key left `del` at write time, so no
643
+ // write targets a deleted key.
644
+ if (e.del !== null) {
557
645
  for (const t of e.del) delete n[t];
646
+ e.kc -= e.del.size;
558
647
  e.del = null;
559
648
  }
649
+ // Plain-data grade (sc 2): bare assignment, as flattenOverlay does.
650
+ for (const r of Reflect.ownKeys(t)) e.sc === 2 ? n[r] = t[r] : copyOwn(n, t, r);
560
651
  e.pb = n;
561
652
  e.ovl = false;
653
+ return n;
562
654
  }
563
655
 
564
656
  function ensurePB(e) {
@@ -698,6 +790,7 @@ function ensurePB(e) {
698
790
  if (heldMaskView(e) === null) e.hv = e.v;
699
791
  e.ht = activeTransition ?? PLAIN_HOLD;
700
792
  if (!n && activeTransition !== null) heldAdoptions.add(e);
793
+ heldKeys.set(e, [ t ]);
701
794
  }
702
795
  e.pb = null;
703
796
  // Overlay and accessor-scan state describe the OUTGOING backing — a
@@ -829,6 +922,7 @@ function queueFold(e) {
829
922
  for (const r of Reflect.ownKeys(t)) e.sc === 2 ? n[r] = t[r] : copyOwn(n, t, r);
830
923
  if (e.del !== null) {
831
924
  for (const t of e.del) delete n[t];
925
+ e.kc -= e.del.size;
832
926
  e.del = null;
833
927
  }
834
928
  e.pb = null;
@@ -887,11 +981,13 @@ function drainFolds() {
887
981
  // re-queue: commit happens when the hold settles
888
982
  continue;
889
983
  }
890
- if (t.ovl) {
984
+ if (t.ovl && (t.v !== n || !overlayRebuilds(t, i))) {
891
985
  // Overlay flatten (#3044): the backing keeps its identity, so the
892
986
  // `t.v === old` gate below skips path copying (the parent slot
893
987
  // already points here) and the adopted-notify (setter notifications
894
- // happened at write time).
988
+ // happened at write time). A backing privatized mid-batch is a fresh
989
+ // clone, never a prototype — in-place writes into it are cheap, and
990
+ // the merge branch below is what a materialized pb would need anyway.
895
991
  flattenOverlay(t, i);
896
992
  } else if (t.v !== n) {
897
993
  // Privatized mid-batch (#3271): an earlier fold in this drain
@@ -932,7 +1028,10 @@ function drainFolds() {
932
1028
  t.wk = null;
933
1029
  // written-keys window closes with the fold commit
934
1030
  } else {
935
- t.v = i;
1031
+ // An overlay whose fold changed the key set rebuilds (#3689) and
1032
+ // takes the swap like a clone-path draft: the parent slot re-points
1033
+ // in the path copy below.
1034
+ t.v = t.ovl ? materializePB(t) : i;
936
1035
  t.ch = false;
937
1036
  // pb is always a plain clone
938
1037
  t.pb = null;
@@ -1162,8 +1261,12 @@ function sameLeaf(e, t) {
1162
1261
  if (e.ht !== null) e.ht = e.hv = null;
1163
1262
  // Overlay landing (#3352): flatten in place — identity-stable, and
1164
1263
  // privatizeCommitted re-slots the parent itself when it has to clone an
1165
- // unowned seed, so no path copy is needed.
1166
- if (e.ovl) return flattenOverlay(e, t);
1264
+ // unowned seed, so no path copy is needed. A landing that changed the
1265
+ // key set rebuilds instead (#3689) and swaps like a clone-path landing.
1266
+ if (e.ovl) {
1267
+ if (!overlayRebuilds(e, t)) return flattenOverlay(e, t);
1268
+ t = materializePB(e);
1269
+ }
1167
1270
  const n = e.v;
1168
1271
  e.pb = null;
1169
1272
  e.v = t;
@@ -1405,21 +1508,25 @@ const UNSAFE_KEYS = new Set([ "__proto__", "prototype", "constructor" ]);
1405
1508
  return false;
1406
1509
  }
1407
1510
 
1408
- function readSource(e) {
1511
+ /** The backing a reader is served. `key` (the get/has/descriptor traps)
1512
+ * scopes a fold hold to the keys the fold touched — see pendingBackingVisible —
1513
+ * and an adoption hold to the keys the adoption changed (heldKey). */ function readSource(e, t) {
1409
1514
  // Adoption hold first (#3074): an adoption staged under a live transaction
1410
1515
  // (or a latest()-pull, PLAIN_HOLD) serves the pre-hold committed view to
1411
1516
  // committed-visibility readers — context-free and children-forbidden ones,
1412
1517
  // and stale passes off a foreign hold. Drafts, write-override and latest()
1413
1518
  // see the adopted backing.
1414
- const t = e.ht;
1415
- if (t !== null && !latestReadActive && !inDraft(e) && !getWriteOverride()) {
1416
- const n = heldMaskView(e);
1417
- if (n !== null) {
1519
+ const n = e.ht;
1520
+ if (n !== null && !latestReadActive && !inDraft(e) && !getWriteOverride()) {
1521
+ const r = heldMaskView(e);
1522
+ // A key the adoption left unchanged derives nothing from the hold, for
1523
+ // any reader (#3706): the backing serves it.
1524
+ if (r !== null && (t === undefined || heldKey(e, t))) {
1418
1525
  const e = readerContext();
1419
- if (e === null || e.C & CONFIG_CHILDREN_FORBIDDEN || !holdVisible(t === PLAIN_HOLD ? null : currentTransition(t), e)) return n;
1526
+ if (e === null || e.C & CONFIG_CHILDREN_FORBIDDEN || !holdVisible(n === PLAIN_HOLD ? null : currentTransition(n), e)) return r;
1420
1527
  }
1421
1528
  }
1422
- return pendingBackingVisible(e, false) ? e.pb : e.v;
1529
+ return pendingBackingVisible(e, false, t) ? e.pb : e.v;
1423
1530
  }
1424
1531
 
1425
1532
  /** The single pb-vs-committed visibility decision (#3147), shared by per-key
@@ -1436,7 +1543,7 @@ function readSource(e) {
1436
1543
  * `speculative` is deep()/snapshot's posture: an untrack/deep PEEK that sees
1437
1544
  * ordinary pending staging regardless of owner context (the documented
1438
1545
  * divergence from context-free per-key reads) — but never through a hold:
1439
- * held truth stays masked exactly as it is for per-key readers. */ function pendingBackingVisible(e, t) {
1546
+ * held truth stays masked exactly as it is for per-key readers. */ function pendingBackingVisible(e, t, n) {
1440
1547
  if (e.pb === null) return false;
1441
1548
  // The writer's own channels compose on the pending backing regardless.
1442
1549
  if (inDraft(e) || getWriteOverride()) return true;
@@ -1447,8 +1554,8 @@ function readSource(e) {
1447
1554
  // hold arms below: committed for no pass, the staged backing for the
1448
1555
  // holding pass, stale-of-foreign or A29 for a foreign one.
1449
1556
  if (heldTruthMasked(e)) return false;
1450
- const n = readerContext();
1451
- if (n === null || n.C & CONFIG_CHILDREN_FORBIDDEN) {
1557
+ const r = readerContext();
1558
+ if (r === null || r.C & CONFIG_CHILDREN_FORBIDDEN) {
1452
1559
  // No pass, or a children-forbidden one: the committed frame (A32) —
1453
1560
  // except the speculative peek (deep()/snapshot()), which sees ordinary
1454
1561
  // pending staging but never through a live foreign hold, and a
@@ -1456,11 +1563,30 @@ function readSource(e) {
1456
1563
  // readers UNLESS a transition holds the node commits (downstream async
1457
1564
  // hold — stale committed is the contract; the write-time stamp covers
1458
1565
  // keys with no node, #3336) or the scope is children-forbidden (#3082).
1459
- const r = liveFoldTransition(e);
1460
- if (t) return r === null || ownsHold(r);
1461
- return e.fam !== null && n === null && !foldHeld(e) && r === null;
1566
+ const n = liveFoldTransition(e);
1567
+ if (t) return n === null || ownsHold(n);
1568
+ return e.fam !== null && r === null && !foldHeld(e) && n === null;
1569
+ }
1570
+ const i = liveFoldTransition(e);
1571
+ // A hold changes what a reader of ONE key sees only through the fold's
1572
+ // writes to that key. A key the fold left alone reads the same from either
1573
+ // backing, so a pass reading it derives nothing from the hold: serve
1574
+ // committed, with no transaction entry and no stale replay (#3688 — a memo
1575
+ // reading an unchanged key beside an independent signal was held with
1576
+ // someone else's action). Per-node reads already have this precision (a
1577
+ // node with nothing staged enters nothing; stageHeldKey skips an equal
1578
+ // value); this is the same rule at the container gate. `wk` is the trap's
1579
+ // record of every write and delete this batch (deletes included, on the
1580
+ // overlay and clone paths alike); null (a fold with no trap writes) or
1581
+ // WK_ALL (an array length write) leaves the whole container held. Same
1582
+ // exclusions as heldFoldTransition: an optimistic family's draft is seeded
1583
+ // from node overrides the trap never saw, and a chained backing's
1584
+ // committed layer is a live proxy, not a frozen twin of the clone.
1585
+ if (i !== null && n !== undefined && !e.ch && e.fam?.opt !== true) {
1586
+ const t = e.wk;
1587
+ if (t != null && t !== WK_ALL && !t.has(n)) return false;
1462
1588
  }
1463
- return holdVisible(liveFoldTransition(e), n);
1589
+ return holdVisible(i, r);
1464
1590
  }
1465
1591
 
1466
1592
  /** #3164 fold: HELD truth on an optimistic family — a pending backing
@@ -1756,7 +1882,7 @@ const traps = {
1756
1882
  // observer, so bring the projection computed up to date here — signal/
1757
1883
  // memo parity for latest() reads through a projection.
1758
1884
  if (e.fam !== null && latestReadActive && !inDraft(e) && !getWriteOverride()) pullProjectionForLatest(e);
1759
- const r = readSource(e);
1885
+ const r = readSource(e, t);
1760
1886
  // Overlay delete (#3044): a prototype overlay cannot shadow a delete, so
1761
1887
  // deleted keys are tracked aside and read as absent in the pending view.
1762
1888
  if (e.del !== null && r === e.pb && e.del.has(t)) {
@@ -1866,7 +1992,7 @@ const traps = {
1866
1992
  if (t === $OWNER || t === $RECORD) return false;
1867
1993
  if (pendingCheckActive) witnessAffectsMark(e, t);
1868
1994
  if (e.fam !== null && getObserver() === null && !inDraft(e)) firewallGate(e);
1869
- const n = readSource(e);
1995
+ const n = readSource(e, t);
1870
1996
  let r = t in n;
1871
1997
  // Overlay deletes read as absent in the pending view (#3044).
1872
1998
  if (r && e.del !== null && n === e.pb && e.del.has(t)) r = false;
@@ -1922,7 +2048,7 @@ const traps = {
1922
2048
  if (pendingCheckActive) witnessAffectsMark(e, t);
1923
2049
  const n = getObserver();
1924
2050
  if (e.fam !== null && n === null && !inDraft(e)) firewallGate(e);
1925
- const r = readSource(e);
2051
+ const r = readSource(e, t);
1926
2052
  const i = visibleDescriptor(e, r, t);
1927
2053
  if (!inDraft(e) && n !== null && !observerHoldsKeySet(e, n)) {
1928
2054
  // The node is born from the source's presence (as `has` births it),
@@ -1967,10 +2093,12 @@ const traps = {
1967
2093
  }
1968
2094
  } else {
1969
2095
  if (e.wk !== WK_ALL) (e.wk ??= new Set).add(t);
1970
- // Live own-key estimate for the overlay/clone choice (#3360): `in`
1971
- // sees through an overlay to the committed keys, so this counts keys
1972
- // NEW to the container. Deletes are not un-counted (a stale high
1973
- // count only picks the overlay a little early).
2096
+ // Live own-key estimate for the overlay/clone choice (#3360) and the
2097
+ // overlay's commit choice (#3689): `in` sees through an overlay to the
2098
+ // committed keys, so this counts keys NEW to the container. Overlay
2099
+ // deletes are counted down when they commit (`del` is exact); clone-
2100
+ // path deletes are not (a stale high count on a narrow container only
2101
+ // picks the overlay a little early).
1974
2102
  if (!(t in l)) e.kc++;
1975
2103
  }
1976
2104
  // Own data keys literally named "prototype"/"constructor" land as data —
@@ -180,20 +180,29 @@ const CONFIG_HAS_LANE = 1 << 10;
180
180
  * transaction. A parked node (status propagation stamps `_transition`
181
181
  * without recomputing) recomputed when its source lands otherwise disposed
182
182
  * its committed children mid-hold, running their cleanups before the
183
- * transaction's atomic reveal. Cleared by `commitPendingNode`. */ const CONFIG_HELD_CHILDREN = 1 << 20;
183
+ * transaction's atomic reveal. Cleared by `commitPendingNode`. Transaction
184
+ * work only (A15 lane work and transaction work, #3698): zombies are parked
185
+ * by a pass under a held transaction; a pass over a lane parks a LANE frame
186
+ * instead (`CONFIG_LANE_FRAME`), whatever the node's kind. */ const CONFIG_HELD_CHILDREN = 1 << 20;
184
187
 
185
188
  /** The frame parked in `_pendingFirstChild` / `_pendingDisposal` is a LANE
186
- * frame (#3662, A15 lanes corollary): an effect's lane pass direct-commits,
187
- * so the frame it replaces leaves the screen when the lane's queue applies
188
- * the run (A30) — not at the action's commit like #3404's transaction
189
- * zombies, and not at the pass (a held lane defers the run). Drained by the
190
- * lane's render entry the parking site pushed ahead of the new frame's
191
- * effects (cleanups before side effects), by `commitPendingNode` if a hold
192
- * commits the node first, or with the owner's death. While set the parked
193
- * frame is not a hold (the node is not queued or stamped for it), a
194
- * superseding pass disposes the never-shown live children on the spot, and a
195
- * lane-channel dirty on a member is cancelled (`laneZombie`). Effects only: a
196
- * memo's lane pass publishes an override (A17, #3479). */ const CONFIG_LANE_FRAME = 1 << 26;
189
+ * frame (#3662, #3698; A15 lane work and transaction work): a lane pass —
190
+ * on an effect or a memo alike — publishes into the lane's frame (an
191
+ * effect's run; a memo's derived override, A17), so the frame it replaces
192
+ * leaves the screen when the lane applies (A30) — not at the action's
193
+ * commit like #3404's transaction zombies, and not at the pass (a held lane
194
+ * defers the apply with the frame still displayed). Drained by the lane's
195
+ * render entry the parking site pushed ahead of the new frame's effects
196
+ * (cleanups before side effects), by `commitPendingNode` if a hold commits
197
+ * the node first, or with the owner's death. While set the parked frame is
198
+ * not a hold (the node is not queued or stamped for it — lane work never
199
+ * makes its node transaction work), a superseding pass disposes the
200
+ * never-shown live children on the spot, and a lane-channel dirty on a
201
+ * member is cancelled (`laneZombie`). Ruled 2026-09-28 (#3698): a pass is
202
+ * lane work or transaction work by its owner, never by node kind. #3662
203
+ * flagged effects only, and a memo's lane pass parked a transaction zombie
204
+ * that queued and stamped the memo as the action's pending node, so its
205
+ * next mainline recompute re-entered the hold. */ const CONFIG_LANE_FRAME = 1 << 26;
197
206
 
198
207
  /** In-flight async node whose inputs were PUBLISHED while it was pending: a
199
208
  * batch or transaction committed with the node still `STATUS_PENDING` (an
@@ -146,8 +146,8 @@ function recompute(e, n = false) {
146
146
  bumpNotifyEpoch();
147
147
  const t = e.je;
148
148
  // Lane posture is resolved BEFORE the previous frame is parked below: a
149
- // lane pass on an effect direct-commits (#3662, see the parking site), so
150
- // the decision must be known there. `lane` is applied to
149
+ // lane pass parks a lane frame, not a transaction zombie (#3662, #3698;
150
+ // see the parking site), so the decision must be known there. `lane` is applied to
151
151
  // `currentOptimisticLane` further down, once the previous posture is saved.
152
152
  let i = !!(e.oe & REACTIVE_OPTIMISTIC_DIRTY);
153
153
  let u = null;
@@ -195,20 +195,30 @@ function recompute(e, n = false) {
195
195
  // Tracked effects run after finalizePureQueue, so dispose immediately
196
196
  // instead of deferring. Children built by an uncommitted recompute
197
197
  // (CONFIG_HELD_CHILDREN) die immediately too: no frame ever showed them.
198
- // Everything else is the committed frame's and is deferred as zombies
199
- // until this node's commit — a transaction-owned node included (#3404):
200
- // a parked node's children predate the hold, and tearing them down when
201
- // the source lands ran cleanups before the transaction's atomic reveal.
202
- // A lane pass on an effect parks a LANE frame instead (CONFIG_LANE_FRAME,
203
- // #3662; A15 lanes corollary): the frame it replaces leaves the screen
204
- // when the lane's queue applies this run (A30) — not at the action's
205
- // commit — and a held lane defers that with the frame still displayed.
206
- // The drain is the lane's first render entry for this pass, pushed before
207
- // the pass builds the new frame: cleanups before side effects — the
208
- // retired frame's `onCleanup`s run ahead of every effect callback of the
209
- // new frame. While the frame waits, the live children were never shown:
210
- // a superseding pass disposes them here like held children, and the
211
- // parked frame stays.
198
+ // Everything else is the committed frame's. Which frame retires it is
199
+ // decided by who owns the pass, never by the node's kind (A15 lane work
200
+ // and transaction work, ruled 2026-09-28, #3698). Transaction work — a
201
+ // pass under a held transaction — defers them as zombies until this
202
+ // node's commit, a transaction-owned node included (#3404): a parked
203
+ // node's children predate the hold, and tearing them down when the
204
+ // source lands ran cleanups before the transaction's atomic reveal.
205
+ // Lane work — a pass over a lane, effect or memo alike — parks a LANE
206
+ // frame instead (CONFIG_LANE_FRAME, #3662, #3698): the frame it replaces
207
+ // leaves the screen when the lane's queue applies this pass (A30) — not
208
+ // at the action's commit — and a held lane defers that with the frame
209
+ // still displayed. The drain is the lane's first render entry for this
210
+ // pass, pushed before the pass builds the new frame: cleanups before
211
+ // side effects — the retired frame's `onCleanup`s run ahead of every
212
+ // effect callback of the new frame. While the frame waits, the live
213
+ // children were never shown: a superseding pass disposes them here like
214
+ // held children, and the parked frame stays. Zombies exist for
215
+ // transaction work only: #3662 gated the lane frame on `isEffect`, and a
216
+ // memo's lane pass (its value a derived override, A17) parked a
217
+ // transaction zombie that made the memo a pending node of the action,
218
+ // stamped it, and sent its next mainline recompute through the
219
+ // stamped-memo arm above — a `Show` whose `when` getter owns a
220
+ // compiler-emitted memo held an unrelated sync write for the action's
221
+ // lifetime, against the #3460 ruling that a lane never holds a sync write.
212
222
  if (t === EFFECT_TRACKED || e.C & (CONFIG_HELD_CHILDREN | CONFIG_LANE_FRAME)) disposeChildren(e); else if (e.tn !== null || e.qe !== null) {
213
223
  markDisposal(e);
214
224
  const n = ext(e);
@@ -217,7 +227,7 @@ function recompute(e, n = false) {
217
227
  e.qe = null;
218
228
  e.tn = null;
219
229
  e.En = 0;
220
- if (t && u) {
230
+ if (u) {
221
231
  e.C |= CONFIG_LANE_FRAME;
222
232
  findLane(u).dn[0].push(() => {
223
233
  e.C &= ~CONFIG_LANE_FRAME;
@@ -1,6 +1,6 @@
1
1
  import { NOT_PENDING, unwrapOverride, STATUS_UNINITIALIZED, REACTIVE_DIRTY, REACTIVE_CHECK, CONFIG_HAS_LANE, OVERRIDE_UNDEFINED, STATUS_PENDING, CONFIG_DERIVED_OVERRIDE, CONFIG_OPTIMISTIC, CONFIG_OVERRIDE_SUPERSEDED, EFFECT_RENDER, CONFIG_AUTHORITATIVE_OBSERVED, REACTIVE_MANUAL_WRITE, REACTIVE_OPTIMISTIC_DIRTY, LANE_RUN, EFFECT_USER } from "./constants.js";
2
2
 
3
- import { ext, hasActiveOverride, stale, currentOptimisticLane, enterStagedRead, latestReadActive } from "./core.js";
3
+ import { ext, hasActiveOverride, stale, currentOptimisticLane, enterStagedRead, setSignal, latestReadActive } from "./core.js";
4
4
 
5
5
  import { NotReadyError } from "./error.js";
6
6
 
@@ -25,17 +25,17 @@ import { GlobalQueue, activeTransition, globalQueue, origin, clock, insertSubs,
25
25
  */
26
26
  /** The optimistic half of setSignal, fired when `_overrideValue !== undefined`. */ function optimisticWrite(e, n) {
27
27
  const i = e.o?.Fe !== NOT_PENDING;
28
- const t = i ? unwrapOverride(e.o?.Fe) : e.ce;
29
- if (typeof n === "function") n = n(t);
30
- const r = !!(e.h & STATUS_UNINITIALIZED) ||
28
+ const r = i ? unwrapOverride(e.o?.Fe) : e.ce;
29
+ if (typeof n === "function") n = n(r);
30
+ const t = !!(e.h & STATUS_UNINITIALIZED) ||
31
31
  // A dirty node's _value is stale (its queued recompute hasn't run — e.g.
32
32
  // a latest() shadow marked by the previous landing's companion snap), so
33
33
  // equality against it must not swallow the write. Without this, a sync
34
34
  // push returning the shadow to that stale value was dropped, the snap
35
35
  // recompute then committed the parent's old value, and the banner showed
36
36
  // the previous transition's target (#3041 follow-up).
37
- !!((e.oe ?? 0) & (REACTIVE_DIRTY | REACTIVE_CHECK)) || !e.xe || !e.xe(t, n);
38
- if (!r) {
37
+ !!((e.oe ?? 0) & (REACTIVE_DIRTY | REACTIVE_CHECK)) || !e.xe || !e.xe(r, n);
38
+ if (!t) {
39
39
  // Same-value write with an active override still entangles the current
40
40
  // action's transition — the hold must outlast all overlapping actions —
41
41
  // and renews the override's PROVENANCE: the newer action re-asks the
@@ -162,7 +162,7 @@ function resolveOptimisticNodes(e) {
162
162
  // override-covered authoritative values hold in _pendingValue and
163
163
  // elevate on their OWN transition's schedule (A18 as re-ruled 2026-07-07).
164
164
  if (!(n.h & STATUS_PENDING)) n.h &= ~STATUS_UNINITIALIZED;
165
- const t = n.o?.Fe;
165
+ const r = n.o?.Fe;
166
166
  // A derived override (lanes stage, #3479) has no truth of its own: the
167
167
  // slot disarms — the memo is plain again — and the override PROMOTES to
168
168
  // `_value`. Not superseded, nothing it derives from told it otherwise
@@ -172,15 +172,15 @@ function resolveOptimisticNodes(e) {
172
172
  // the override IS what a recompute from the truth yields. Re-deriving
173
173
  // instead re-asked an async member's flight and held the transaction on
174
174
  // it — a waterfall after the reveal.
175
- const r = n.C & CONFIG_DERIVED_OVERRIDE;
176
- ext(n).Fe = r && !(n.C & CONFIG_OPTIMISTIC) ? undefined : NOT_PENDING;
175
+ const t = n.C & CONFIG_DERIVED_OVERRIDE;
176
+ ext(n).Fe = t && !(n.C & CONFIG_OPTIMISTIC) ? undefined : NOT_PENDING;
177
177
  // A superseded override's subscribers already re-derived from the truth
178
178
  // when it arrived (#3331) — the drop changes nothing they read. Everyone
179
179
  // else learns of the correction here: this drop IS their notification.
180
180
  const u = (n.C & CONFIG_OVERRIDE_SUPERSEDED) !== 0;
181
181
  n.C &= -8912897;
182
- if (!u && t !== NOT_PENDING && n.ce !== unwrapOverride(t)) {
183
- if (r) n.ce = unwrapOverride(t); else {
182
+ if (!u && r !== NOT_PENDING && n.ce !== unwrapOverride(r)) {
183
+ if (t) n.ce = unwrapOverride(r); else {
184
184
  insertSubs(n, true);
185
185
  }
186
186
  }
@@ -192,9 +192,9 @@ function resolveOptimisticNodes(e) {
192
192
  // transition that produced them (A19 — pending is a property of the data).
193
193
  for (let i = 0; i < n; i++) {
194
194
  const n = e[i];
195
- if (n.o?.Ze || n.o?.He) GlobalQueue.ti(n);
196
- const t = n.o?.kn;
197
- if (t && (t.o?.Ze === n || t.o?.He === n)) GlobalQueue.ti(t);
195
+ if (n.o?.Ze || n.o?.He) GlobalQueue.ri(n);
196
+ const r = n.o?.kn;
197
+ if (r && (r.o?.Ze === n || r.o?.He === n)) GlobalQueue.ri(r);
198
198
  }
199
199
  e.splice(0, n);
200
200
  }
@@ -248,14 +248,14 @@ function resolveOptimisticNodes(e) {
248
248
  const n = e.o?.me;
249
249
  if (n) {
250
250
  const i = findLane(n);
251
- const t = [ e ];
252
- while (t.length) {
253
- const e = t.pop();
251
+ const r = [ e ];
252
+ while (r.length) {
253
+ const e = r.pop();
254
254
  const n = e.o?.me;
255
255
  if (!n || findLane(n) !== i) continue;
256
256
  e.o.me = undefined;
257
257
  i.Qe.delete(e);
258
- for (let n = e.u; n !== null; n = n.Ae) t.push(n.ge);
258
+ for (let n = e.u; n !== null; n = n.Ae) r.push(n.ge);
259
259
  }
260
260
  }
261
261
  }
@@ -297,7 +297,7 @@ function resolveOptimisticNodes(e) {
297
297
  * the optimistic path so it flushes ahead of the hold — but they answer for
298
298
  * their owner and snap at settlement (`_snapCompanions`), not here.
299
299
  */ function endOptimism(e) {
300
- if (!e.Te || e.pe.length || !e.cn.length || e.ri.size || transitionBlocked(e)) return false;
300
+ if (!e.Te || e.pe.length || !e.cn.length || e.ti.size || transitionBlocked(e)) return false;
301
301
  for (const n of e.le.keys()) if (sourceObserved(e, n, e) && n.o?.ue?.has(n) && !resolveLane(n)) return false;
302
302
  let n = false;
303
303
  for (const i of e.cn) {
@@ -393,7 +393,49 @@ function resolveOptimisticNodes(e) {
393
393
  * action provenance, or an A17-silent confirmation (#3331). Before this the
394
394
  * landing took setSignal's plain path: a differing truth staged silently
395
395
  * under the override and the graph never moved until the commit.
396
+ *
397
+ * Slot arm (list-matrix F1): `mapArray`'s writes to its per-slot signals —
398
+ * the row accessors of index mode, the index accessors of keyed mode; never
399
+ * CONFIG_OPTIMISTIC, which is how the arm tells them from the store landings
400
+ * above — once a lane pass has run over the map. The list's frame lives in
401
+ * these writes as much as in the computed's result. Under a LANE pass the
402
+ * write is the lane's frame, as the computed's result is (lanes stage,
403
+ * `laneOverride`): the slot joins the lane carrying a DERIVED override —
404
+ * `_value` stays the committed row, the lane's readers and untracked reads
405
+ * see the override (A17), an off-lane render effect sees the committed frame
406
+ * (#3460), the revert promotes or drops it with the lane's transaction. The
407
+ * gate compares against the slot the pass publishes to (INV-11): the override
408
+ * when armed, the committed row otherwise. A plain `setSignal` here staged
409
+ * the write into the ACTION's transaction: `<For>` without `keyed` showed the
410
+ * pre-action list for the whole action while the keyed modes showed the
411
+ * optimistic one. A PLAIN pass over a slot still carrying the lane's frame —
412
+ * the landing, the reversion, a mainline re-pass — is the truth arriving
413
+ * under an override and takes the landing path below: a differing truth
414
+ * supersedes, an equal one confirms and the revert promotes. Every pass
415
+ * rewrites every slot whose value differs from the previous frame's (index
416
+ * mode rewrites every surviving slot), so a slot the landing does NOT write
417
+ * holds, by construction, what the truth yields. A slot with neither a lane
418
+ * nor an override is the plain write.
396
419
  */ function landOnOverride(e, n) {
420
+ if (!(e.C & CONFIG_OPTIMISTIC)) {
421
+ const i = currentOptimisticLane;
422
+ if (i === null) {
423
+ if (!hasActiveOverride(e)) return setSignal(e, n);
424
+ } else {
425
+ // INV-11: the gate compares against the slot this pass publishes to —
426
+ // the override when one is armed, the committed row otherwise.
427
+ if (!e.xe || !e.xe(hasActiveOverride(e) ? unwrapOverride(e.o.Fe) : e.ce, n)) {
428
+ // Membership before the publish: `laneOverride` files the slot under
429
+ // the lane's transaction, and the read path routes a tracked reader
430
+ // of a lane member through `overrideRead` (CONFIG_HAS_LANE).
431
+ assignOrMergeLane(e, i);
432
+ laneOverride(e, n, i);
433
+ insertSubs(e, true);
434
+ schedule();
435
+ }
436
+ return n;
437
+ }
438
+ }
397
439
  const i = e._e === NOT_PENDING ? e.ce : e._e;
398
440
  if (typeof n === "function") n = n(i);
399
441
  if (e._e === NOT_PENDING) queuePendingNode(e);
@@ -583,7 +625,7 @@ function cleanupCompletedLanes(e) {
583
625
 
584
626
  function trackOptimisticStore(e) {
585
627
  // After initTransition, globalQueue._batch IS activeTransition (same reference)
586
- globalQueue.m.ri.add(e);
628
+ globalQueue.m.ti.add(e);
587
629
  schedule();
588
630
  }
589
631
 
@@ -58,13 +58,13 @@ function disposeChildren(e, n = false, t) {
58
58
  // edge). Snap runs after the DISPOSED flag is set so the oracle reads
59
59
  // false, and notifies subscribers still watching the companion.
60
60
  const n = e;
61
- if (n.o?.Ze || n.o?.He) GlobalQueue.ti(n);
61
+ if (n.o?.Ze || n.o?.He) GlobalQueue.ri(n);
62
62
  // A firewall's leaves have no lifecycle of their own, so a companion on
63
63
  // one of them outlives its source the same way (INV-9's rationale). The
64
64
  // firewall knows which leaves carry companions (CONFIG_CHILD_COMPANIONS,
65
65
  // #3038): snap them with it — the snap retires a shadow whose firewall is
66
66
  // disposed (spec O5).
67
- if (n.C & CONFIG_CHILD_COMPANIONS) n.o.Un.forEach(GlobalQueue.ti);
67
+ if (n.C & CONFIG_CHILD_COMPANIONS) n.o.Un.forEach(GlobalQueue.ri);
68
68
  // A pending reader parked in a transaction may be the only thing holding
69
69
  // it (#3372): its death is a completion event the transaction must be
70
70
  // re-judged for, and nothing else re-enters a parked transaction.