@solidjs/signals 2.0.0-beta.19 → 2.0.0-beta.21

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 (37) hide show
  1. package/README.md +5 -1
  2. package/dist/dev.js +753 -378
  3. package/dist/node.cjs +1947 -1607
  4. package/dist/prod/affects.js +17 -21
  5. package/dist/prod/core/action.js +66 -15
  6. package/dist/prod/core/async.js +57 -71
  7. package/dist/prod/core/constants.js +15 -1
  8. package/dist/prod/core/core.js +39 -39
  9. package/dist/prod/core/effect.js +14 -14
  10. package/dist/prod/core/graph.js +1 -1
  11. package/dist/prod/core/heap.js +11 -3
  12. package/dist/prod/core/lanes.js +23 -13
  13. package/dist/prod/core/optimistic.js +107 -99
  14. package/dist/prod/core/owner.js +23 -23
  15. package/dist/prod/core/scheduler.js +188 -181
  16. package/dist/prod/core/verdict.js +29 -15
  17. package/dist/prod/map.js +196 -167
  18. package/dist/prod/signals.js +1 -1
  19. package/dist/prod/store/optimistic.js +103 -68
  20. package/dist/prod/store/reconcile.js +80 -46
  21. package/dist/prod/store/store.js +204 -73
  22. package/dist/prod/store/utils.js +61 -42
  23. package/dist/types/core/action.d.ts +35 -6
  24. package/dist/types/core/constants.d.ts +12 -0
  25. package/dist/types/core/dev.d.ts +7 -0
  26. package/dist/types/core/lanes.d.ts +2 -0
  27. package/dist/types/core/scheduler.d.ts +2 -6
  28. package/dist/types/core/types.d.ts +9 -1
  29. package/dist/types/store/store.d.ts +8 -2
  30. package/dist/types-cjs/core/action.d.cts +35 -6
  31. package/dist/types-cjs/core/constants.d.cts +12 -0
  32. package/dist/types-cjs/core/dev.d.cts +7 -0
  33. package/dist/types-cjs/core/lanes.d.cts +2 -0
  34. package/dist/types-cjs/core/scheduler.d.cts +2 -6
  35. package/dist/types-cjs/core/types.d.cts +9 -1
  36. package/dist/types-cjs/store/store.d.cts +8 -2
  37. package/package.json +1 -1
package/dist/dev.js CHANGED
@@ -91,6 +91,20 @@ const EFFECT_USER = 2;
91
91
  const EFFECT_TRACKED = 3;
92
92
  const NOT_PENDING = {};
93
93
  const NO_SNAPSHOT = {};
94
+ /**
95
+ * Stand-in stored in `_overrideValue` for an optimistic write of literal
96
+ * `undefined` (#2898). The slot doubles as the optimistic-node brand
97
+ * (`undefined` = not optimistic, `NOT_PENDING` = at rest), so the raw value
98
+ * would erase the node's optimistic identity: the write turns invisible and
99
+ * follow-up writes route off the optimistic path and commit permanently.
100
+ * Same shape as NO_SNAPSHOT. Sites that surface the override VALUE unwrap
101
+ * via `visibleOverrideValue`; slot identity tests stay raw.
102
+ */
103
+ const OVERRIDE_UNDEFINED = {};
104
+ /** Unwrap an active override's stored value for surfacing to readers (#2898). */
105
+ function unwrapOverride(v) {
106
+ return v === OVERRIDE_UNDEFINED ? undefined : v;
107
+ }
94
108
  const STORE_SNAPSHOT_PROPS = "sp";
95
109
  const SUPPORTS_PROXY = typeof Proxy === "function";
96
110
  const defaultContext = {};
@@ -147,6 +161,39 @@ function emitDiagnostic(event) {
147
161
  for (const capture of diagnosticCaptures) capture.push(entry);
148
162
  return entry;
149
163
  }
164
+ /**
165
+ * Shared strict-read diagnostics for core read() and the store proxy traps.
166
+ * Single source for the message text — the #2897 safeguard parity between
167
+ * memos and stores is exactly these firing identically from both paths.
168
+ */
169
+ function throwPendingUntrackedRead(strictReadLabel, fields) {
170
+ const message =
171
+ `[PENDING_ASYNC_UNTRACKED_READ] Reading a pending async value directly in ${strictReadLabel}. ` +
172
+ `Async values must be read within a tracking scope (JSX, a memo, or an effect's compute function).`;
173
+ emitDiagnostic({
174
+ code: "PENDING_ASYNC_UNTRACKED_READ",
175
+ kind: "async",
176
+ severity: "error",
177
+ message,
178
+ ...fields,
179
+ data: { strictRead: strictReadLabel }
180
+ });
181
+ throw new Error(message);
182
+ }
183
+ function warnStrictReadUntracked(strictReadLabel, fields) {
184
+ const message =
185
+ `[STRICT_READ_UNTRACKED] Reactive value read directly in ${strictReadLabel} will not update. ` +
186
+ `Move it into a tracking scope (JSX, a memo, or an effect's compute function).`;
187
+ emitDiagnostic({
188
+ code: "STRICT_READ_UNTRACKED",
189
+ kind: "strict-read",
190
+ severity: "warn",
191
+ message,
192
+ data: { strictRead: strictReadLabel },
193
+ ...fields
194
+ });
195
+ console.warn(message);
196
+ }
150
197
  function registerGraph(value, owner) {
151
198
  value._owner = owner;
152
199
  if (owner) {
@@ -320,6 +367,16 @@ function resolveLane(el) {
320
367
  return undefined;
321
368
  }
322
369
  function resolveTransition(el) {
370
+ // An active override answers with its owner, not its lane: lanes are
371
+ // scheduling affinity and a shared subscriber merges them across
372
+ // transactions (#2912) — the merged root's _transition would hand this
373
+ // node's override to whichever action wrote last through the shared
374
+ // reader. Chase merge chains; a dead owner settled through another path.
375
+ if (hasActiveOverride(el) && el._overrideOwner) {
376
+ const owner = (el._overrideOwner = currentTransition(el._overrideOwner));
377
+ if (owner._done !== true) return owner;
378
+ el._overrideOwner = null;
379
+ }
323
380
  return resolveLane(el)?._transition ?? el._transition;
324
381
  }
325
382
  /**
@@ -391,13 +448,14 @@ function registerTransientStoreNode(node) {
391
448
  transientStoreNodes.add(node);
392
449
  }
393
450
  function canUseSimpleSyncFlush(queue) {
451
+ const batch = queue._batch;
394
452
  return (
395
453
  transitions.size === 0 &&
396
454
  activeLanes.size === 0 &&
397
455
  queue._children.length === 0 &&
398
- queue._optimisticNodes.length === 0 &&
399
- queue._affectsNodes.length === 0 &&
400
- queue._optimisticStores.size === 0 &&
456
+ batch._optimisticNodes.length === 0 &&
457
+ batch._affectsNodes.length === 0 &&
458
+ batch._optimisticStores.size === 0 &&
401
459
  transientStoreNodes.size === 0
402
460
  );
403
461
  }
@@ -438,20 +496,43 @@ function setProjectionWriteActive(value) {
438
496
  function setTrackedQueueCallback(value) {
439
497
  inTrackedQueueCallback = value;
440
498
  }
499
+ /**
500
+ * Ambient work IS a transaction: the global queue always carries one
501
+ * current-transaction-shaped batch (`globalQueue._batch`). With no transition
502
+ * active, registrations (pending commits, optimistic nodes, affects marks,
503
+ * optimistic stores) land in a plain ambient batch that the plain flush
504
+ * finalizes; when a transition initializes it adopts the ambient batch's
505
+ * contents and `_batch` becomes the transition itself, so later registrations
506
+ * land there directly — no per-field aliasing.
507
+ */
508
+ function createBatch() {
509
+ return {
510
+ _time: clock,
511
+ _pendingNodes: [],
512
+ _asyncReporters: createAsyncReporters(),
513
+ _optimisticNodes: [],
514
+ _affectsNodes: [],
515
+ _optimisticStores: new Set(),
516
+ _actions: [],
517
+ _queueStash: { _queues: [[], []], _children: [] },
518
+ _done: false,
519
+ _gatedSubs: new Set()
520
+ };
521
+ }
441
522
  function mergeTransitionState(target, outgoing) {
442
523
  outgoing._done = target;
443
524
  target._actions.push(...outgoing._actions);
444
525
  for (const lane of activeLanes) if (lane._transition === outgoing) lane._transition = target;
445
526
  if (outgoing._optimisticNodes.length) {
446
- // Move (don't copy): the global queue may still alias the outgoing
447
- // array, and the adoption pass in initTransition would re-push its
527
+ // Move (don't copy): the global queue's batch may still be the outgoing
528
+ // transition, and the adoption pass in initTransition would re-push its
448
529
  // contents into the target — duplicating every entry.
449
530
  target._optimisticNodes.push(...outgoing._optimisticNodes);
450
531
  outgoing._optimisticNodes.length = 0;
451
532
  }
452
533
  if (outgoing._affectsNodes.length) {
453
- // Move (don't copy): the global queue may still alias the outgoing
454
- // array, and the adoption pass in initTransition would re-push its
534
+ // Move (don't copy): the global queue's batch may still be the outgoing
535
+ // transition, and the adoption pass in initTransition would re-push its
455
536
  // contents into the target — double-releasing every mark.
456
537
  target._affectsNodes.push(...outgoing._affectsNodes);
457
538
  outgoing._affectsNodes.length = 0;
@@ -576,11 +657,9 @@ class Queue {
576
657
  }
577
658
  class GlobalQueue extends Queue {
578
659
  _running = false;
579
- _pendingNode = null;
580
- _pendingNodes = [];
581
- _optimisticNodes = [];
582
- _affectsNodes = [];
583
- _optimisticStores = new Set();
660
+ // The current transaction-shaped batch: a plain ambient batch while no
661
+ // transition is active, the active transition itself after initTransition.
662
+ _batch = createBatch();
584
663
  static _update;
585
664
  static _dispose;
586
665
  static _runEffect;
@@ -651,11 +730,13 @@ class GlobalQueue extends Queue {
651
730
  if (!isComplete) {
652
731
  const stashedTransition = activeTransition;
653
732
  runHeap(zombieQueue, GlobalQueue._update);
654
- this._pendingNode = null;
655
- this._pendingNodes = [];
656
- this._optimisticNodes = [];
657
- this._affectsNodes = [];
658
- this._optimisticStores = new Set();
733
+ // Detach: the stashed transition keeps its batch; ambient work that
734
+ // follows lands in a fresh one. If the batch is already a separate
735
+ // ambient one — action done() restored activeTransition without
736
+ // adopting the batch, and an ordinary write landed there before
737
+ // the scheduled flush (#2916) — keep it: replacing it would strand
738
+ // its queued pending nodes with held _pendingValues forever.
739
+ if (this._batch === stashedTransition) currentBatch = this._batch = createBatch();
659
740
  // Run lane effects immediately (before stashing) - lanes with no pending async
660
741
  if (activeLanes.size) {
661
742
  GlobalQueue._runLaneEffects(EFFECT_RENDER);
@@ -663,7 +744,10 @@ class GlobalQueue extends Queue {
663
744
  }
664
745
  this.stashQueues(stashedTransition._queueStash);
665
746
  clock++;
666
- scheduled = dirtyQueue._max >= dirtyQueue._min;
747
+ // A kept ambient batch may hold pending nodes (#2916): stay
748
+ // scheduled so the outer drain loop commits them via the plain
749
+ // flush path instead of leaving them until the next natural flush.
750
+ scheduled = dirtyQueue._max >= dirtyQueue._min || this._batch._pendingNodes.length > 0;
667
751
  reassignPendingTransition(stashedTransition._pendingNodes);
668
752
  activeTransition = null;
669
753
  // The stash pass (committed-view rerun of plain optimistic signals)
@@ -680,14 +764,26 @@ class GlobalQueue extends Queue {
680
764
  }
681
765
  return;
682
766
  }
683
- this._pendingNodes !== activeTransition._pendingNodes &&
684
- this._pendingNodes.push(...activeTransition._pendingNodes);
685
- this.restoreQueues(activeTransition._queueStash);
686
- transitions.delete(activeTransition);
687
767
  const completingTransition = activeTransition;
768
+ const batch = this._batch;
769
+ batch !== completingTransition &&
770
+ batch._pendingNodes.push(...completingTransition._pendingNodes);
771
+ this.restoreQueues(completingTransition._queueStash);
772
+ transitions.delete(completingTransition);
688
773
  activeTransition = null;
689
- reassignPendingTransition(this._pendingNodes);
774
+ reassignPendingTransition(batch._pendingNodes);
690
775
  finalizePureQueue(completingTransition);
776
+ if (batch === completingTransition) {
777
+ // Drop the dead Transition wrapper but keep its (drained) containers
778
+ // as the ambient batch — late registrations during finalization live
779
+ // there and must survive to the next flush.
780
+ const fresh = createBatch();
781
+ fresh._pendingNodes = batch._pendingNodes;
782
+ fresh._optimisticNodes = batch._optimisticNodes;
783
+ fresh._affectsNodes = batch._affectsNodes;
784
+ fresh._optimisticStores = batch._optimisticStores;
785
+ currentBatch = this._batch = fresh;
786
+ }
691
787
  } else {
692
788
  if (canUseSimpleSyncFlush(this)) {
693
789
  commitPendingNodes();
@@ -710,12 +806,12 @@ class GlobalQueue extends Queue {
710
806
  this.run(EFFECT_USER);
711
807
  if (true) {
712
808
  devCheckActiveOverrides(n => {
713
- if (this._optimisticNodes.includes(n)) return true;
809
+ if (this._batch._optimisticNodes.includes(n)) return true;
714
810
  if (activeTransition?._optimisticNodes.includes(n)) return true;
715
811
  for (const t of transitions) if (t._optimisticNodes.includes(n)) return true;
716
812
  return false;
717
813
  });
718
- devCensusCompanions(n => n === this._pendingNode || this._pendingNodes.includes(n));
814
+ devCensusCompanions(n => this._batch._pendingNodes.includes(n));
719
815
  }
720
816
  if (
721
817
  true &&
@@ -725,7 +821,7 @@ class GlobalQueue extends Queue {
725
821
  activeLanes.size === 0
726
822
  ) {
727
823
  // Fully drained: no transition-scoped state may survive this point.
728
- devCheckQuiescent(n => n === this._pendingNode || this._pendingNodes.includes(n));
824
+ devCheckQuiescent(n => this._batch._pendingNodes.includes(n));
729
825
  }
730
826
  if (true) DEV$1.hooks.onUpdate?.();
731
827
  } finally {
@@ -756,18 +852,7 @@ class GlobalQueue extends Queue {
756
852
  if (transition && transition === activeTransition) return;
757
853
  if (!transition && activeTransition && activeTransition._time === clock) return;
758
854
  if (!activeTransition) {
759
- activeTransition = transition ?? {
760
- _time: clock,
761
- _pendingNodes: [],
762
- _asyncReporters: createAsyncReporters(),
763
- _optimisticNodes: [],
764
- _affectsNodes: [],
765
- _optimisticStores: new Set(),
766
- _actions: [],
767
- _queueStash: { _queues: [[], []], _children: [] },
768
- _done: false,
769
- _gatedSubs: new Set()
770
- };
855
+ activeTransition = transition ?? createBatch();
771
856
  } else if (transition) {
772
857
  const outgoing = activeTransition;
773
858
  mergeTransitionState(transition, outgoing);
@@ -776,60 +861,36 @@ class GlobalQueue extends Queue {
776
861
  }
777
862
  transitions.add(activeTransition);
778
863
  activeTransition._time = clock;
779
- if (this._pendingNode !== null) {
780
- this._pendingNode._transition = activeTransition;
781
- activeTransition._pendingNodes.push(this._pendingNode);
782
- this._pendingNode = null;
783
- }
784
- if (this._pendingNodes !== activeTransition._pendingNodes) {
785
- for (let i = 0; i < this._pendingNodes.length; i++) {
786
- const node = this._pendingNodes[i];
864
+ const batch = this._batch;
865
+ if (batch !== activeTransition) {
866
+ // Adopt the ambient batch into the transaction, then make the
867
+ // transaction the batch so later registrations land there directly.
868
+ // Pending and optimistic nodes are re-stamped as the transaction's;
869
+ // marks don't hijack the node's _transition — a mark on a plain signal
870
+ // must not entangle unrelated writes to it; the same rule holds one hop
871
+ // downstream: propagation never queues pended subscribers as pending
872
+ // nodes, see propagateAffectsMark, #2893.
873
+ for (let i = 0; i < batch._pendingNodes.length; i++) {
874
+ const node = batch._pendingNodes[i];
787
875
  node._transition = activeTransition;
788
876
  activeTransition._pendingNodes.push(node);
789
877
  }
790
- this._pendingNodes = activeTransition._pendingNodes;
791
- }
792
- if (this._optimisticNodes !== activeTransition._optimisticNodes) {
793
- for (let i = 0; i < this._optimisticNodes.length; i++) {
794
- const node = this._optimisticNodes[i];
878
+ for (let i = 0; i < batch._optimisticNodes.length; i++) {
879
+ const node = batch._optimisticNodes[i];
795
880
  node._transition = activeTransition;
796
881
  activeTransition._optimisticNodes.push(node);
797
882
  }
798
- this._optimisticNodes = activeTransition._optimisticNodes;
799
- }
800
- if (this._affectsNodes !== activeTransition._affectsNodes) {
801
- // Adopt ambient marks into the transaction (marks don't hijack the
802
- // node's _transition — a mark on a plain signal must not entangle
803
- // unrelated writes to it; the same rule holds one hop downstream:
804
- // propagation never queues pended subscribers as pending nodes, see
805
- // propagateAffectsMark, #2893). After adoption the queue aliases the
806
- // transition's array, so later registrations land there directly.
807
- activeTransition._affectsNodes.push(...this._affectsNodes);
808
- this._affectsNodes = activeTransition._affectsNodes;
883
+ if (batch._affectsNodes.length) activeTransition._affectsNodes.push(...batch._affectsNodes);
884
+ for (const store of batch._optimisticStores) activeTransition._optimisticStores.add(store);
885
+ currentBatch = this._batch = activeTransition;
809
886
  }
810
887
  for (const lane of activeLanes) {
811
888
  if (!lane._transition) lane._transition = activeTransition;
812
889
  }
813
- if (this._optimisticStores !== activeTransition._optimisticStores) {
814
- for (const store of this._optimisticStores) activeTransition._optimisticStores.add(store);
815
- this._optimisticStores = activeTransition._optimisticStores;
816
- }
817
890
  }
818
891
  }
819
892
  function queuePendingNode(node) {
820
- if (activeTransition) {
821
- globalQueue._pendingNodes.push(node);
822
- return;
823
- }
824
- if (globalQueue._pendingNode === null && globalQueue._pendingNodes.length === 0) {
825
- globalQueue._pendingNode = node;
826
- return;
827
- }
828
- if (globalQueue._pendingNode !== null) {
829
- globalQueue._pendingNodes.push(globalQueue._pendingNode);
830
- globalQueue._pendingNode = null;
831
- }
832
- globalQueue._pendingNodes.push(node);
893
+ currentBatch._pendingNodes.push(node);
833
894
  }
834
895
  // Sticky: flips true on the first refresh() ever (the only setter of
835
896
  // REACTIVE_REASK) so the hot notification loop skips the per-subscriber flag
@@ -886,11 +947,7 @@ function commitPendingNode(n) {
886
947
  if (n._pendingSignal || n._latestValueComputed) GlobalQueue._snapCompanions(n);
887
948
  }
888
949
  function commitPendingNodes() {
889
- if (globalQueue._pendingNode !== null) {
890
- commitPendingNode(globalQueue._pendingNode);
891
- globalQueue._pendingNode = null;
892
- }
893
- const pendingNodes = globalQueue._pendingNodes;
950
+ const pendingNodes = currentBatch._pendingNodes;
894
951
  for (let i = 0; i < pendingNodes.length; i++) {
895
952
  commitPendingNode(pendingNodes[i]);
896
953
  }
@@ -906,12 +963,11 @@ function finalizePureQueue(completingTransition = null, incomplete = false) {
906
963
  if (ranHeap) runHeap(dirtyQueue, GlobalQueue._update);
907
964
  if (resolvePending) {
908
965
  if (ranHeap) commitPendingNodes();
966
+ // The settling batch: the completing transaction's, or the ambient one.
967
+ const batch = completingTransition ?? globalQueue._batch;
909
968
  // Optimistic reversion: a non-empty batch means _optimisticWrite ran,
910
969
  // which installed the engine's hooks.
911
- const optimisticNodes = completingTransition
912
- ? completingTransition._optimisticNodes
913
- : globalQueue._optimisticNodes;
914
- if (optimisticNodes.length) GlobalQueue._resolveOptimistic(optimisticNodes);
970
+ if (batch._optimisticNodes.length) GlobalQueue._resolveOptimistic(batch._optimisticNodes);
915
971
  // Replay entanglement: subs recorded by the read-time gate get rescheduled
916
972
  // so they re-run with the now-committed values visible.
917
973
  if (completingTransition && completingTransition._gatedSubs.size) {
@@ -924,17 +980,13 @@ function finalizePureQueue(completingTransition = null, incomplete = false) {
924
980
  // Declared motion ends with the transaction: settle (or plain flush end
925
981
  // for ambient marks) releases each registration's refcount. A non-empty
926
982
  // batch means registerAffectsMark ran, which installed the hook.
927
- const affectsNodes = completingTransition
928
- ? completingTransition._affectsNodes
929
- : globalQueue._affectsNodes;
930
- if (affectsNodes.length) GlobalQueue._releaseAffectsMarks(affectsNodes);
931
- const optimisticStores = completingTransition
932
- ? completingTransition._optimisticStores
933
- : globalQueue._optimisticStores;
983
+ if (batch._affectsNodes.length) GlobalQueue._releaseAffectsMarks(batch._affectsNodes);
934
984
  // A non-empty set means trackOptimisticStore ran, which installed the
935
985
  // hook; the hook iterates, clears, and schedules (keeping the loop out of
936
- // core lets esbuild shake it — rollup already folds the null guard).
937
- if (optimisticStores.size) GlobalQueue._clearOptimisticStores(optimisticStores);
986
+ // core lets esbuild shake it — rollup already folds the null guard). The
987
+ // completing transition scopes the clear to its own layer keys (#2899).
988
+ if (batch._optimisticStores.size)
989
+ GlobalQueue._clearOptimisticStores(batch._optimisticStores, completingTransition);
938
990
  sweepTransientStoreNodes();
939
991
  // Lanes only enter activeLanes through the engine's getOrCreateLane.
940
992
  if (activeLanes.size) GlobalQueue._cleanupLanes(completingTransition);
@@ -968,6 +1020,12 @@ function reassignPendingTransition(pendingNodes) {
968
1020
  }
969
1021
  }
970
1022
  const globalQueue = new GlobalQueue();
1023
+ // Hot-path mirror of `globalQueue._batch`: `queuePendingNode` runs once per
1024
+ // staged write and `commitPendingNodes` once per flush, and the extra
1025
+ // property hop through `_batch` was a measured instruction-count regression
1026
+ // (CodSpeed update1to1, PR #2905). The field stays authoritative for
1027
+ // cross-module readers; every `_batch` assignment updates both.
1028
+ let currentBatch = globalQueue._batch;
971
1029
  function flush(fn) {
972
1030
  if (fn) {
973
1031
  syncDepth++;
@@ -1005,7 +1063,7 @@ function runQueue$1(queue, type) {
1005
1063
  }
1006
1064
  function reporterBlocksSource(reporter, source) {
1007
1065
  if (reporter._flags & (REACTIVE_ZOMBIE | REACTIVE_DISPOSED)) return false;
1008
- if (reporter._pendingSource === source || reporter._pendingSources?.has(source)) return true;
1066
+ if (reporter._pendingSources?.has(source)) return true;
1009
1067
  for (let dep = reporter._deps; dep; dep = dep._nextDep) {
1010
1068
  let current = dep._dep;
1011
1069
  while (current) {
@@ -1105,7 +1163,15 @@ function insertIntoHeap(n, heap) {
1105
1163
  if (flags & (REACTIVE_IN_HEAP | REACTIVE_RECOMPUTING_DEPS | REACTIVE_MANUAL_WRITE)) return;
1106
1164
  if (flags & REACTIVE_CHECK) {
1107
1165
  n._flags = (flags & -4) | REACTIVE_DIRTY | REACTIVE_IN_HEAP;
1108
- } else n._flags = flags | REACTIVE_IN_HEAP;
1166
+ } else {
1167
+ n._flags = flags | REACTIVE_IN_HEAP;
1168
+ // An unmarked node entering a marked heap invalidates the markHeap memo:
1169
+ // `_marked` is only reset by runHeap, so a write between two mid-tick
1170
+ // pulls (read-time markHeap + updateIfNecessary) would otherwise leave
1171
+ // this node unmarked and every downstream pull stale until the next
1172
+ // flush (#2922: the second `latest()` returned the first write's value).
1173
+ if (heap._marked && !(flags & REACTIVE_DIRTY)) heap._marked = false;
1174
+ }
1109
1175
  if (!(flags & REACTIVE_IN_HEAP_HEIGHT)) actualInsertIntoHeap(n, heap);
1110
1176
  }
1111
1177
  function insertIntoHeapHeight(n, heap) {
@@ -1585,38 +1651,22 @@ function link(dep, sub, pendingObserver = false) {
1585
1651
  else dep._subs = newLink;
1586
1652
  }
1587
1653
 
1654
+ // The lazily-created Set is the ONE container for pending sources. Its
1655
+ // predecessor — a singular slot promoted to a Set on the second source —
1656
+ // created dual state whose migration invariant was easy to break: a third
1657
+ // overlapping source landed beside the Set and removePendingSource refused
1658
+ // to clear it, stranding the Set members' pending forever (#2893).
1588
1659
  function addPendingSource(el, source) {
1589
- if (el._pendingSource === source || el._pendingSources?.has(source)) return false;
1590
- // Once the Set exists it is THE container — the singular slot stays empty
1591
- // from migration until removePendingSource collapses back to one entry.
1592
- // Landing a third source in the singular slot instead created dual state
1593
- // that removePendingSource refused to clear, stranding the Set members'
1594
- // pending forever (#2893).
1595
- if (el._pendingSources) el._pendingSources.add(source);
1596
- else if (!el._pendingSource) el._pendingSource = source;
1597
- else {
1598
- el._pendingSources = new Set([el._pendingSource, source]);
1599
- el._pendingSource = undefined;
1600
- }
1660
+ if (el._pendingSources?.has(source)) return false;
1661
+ (el._pendingSources ??= new Set()).add(source);
1601
1662
  return true;
1602
1663
  }
1603
1664
  function removePendingSource(el, source) {
1604
- if (el._pendingSource) {
1605
- if (el._pendingSource !== source) return false;
1606
- el._pendingSource = undefined;
1607
- return true;
1608
- }
1609
1665
  if (!el._pendingSources?.delete(source)) return false;
1610
- if (el._pendingSources.size === 1) {
1611
- el._pendingSource = el._pendingSources.values().next().value;
1612
- el._pendingSources = undefined;
1613
- } else if (el._pendingSources.size === 0) {
1614
- el._pendingSources = undefined;
1615
- }
1666
+ if (el._pendingSources.size === 0) el._pendingSources = undefined;
1616
1667
  return true;
1617
1668
  }
1618
1669
  function clearPendingSources(el) {
1619
- el._pendingSource = undefined;
1620
1670
  el._pendingSources?.clear();
1621
1671
  el._pendingSources = undefined;
1622
1672
  }
@@ -1660,7 +1710,7 @@ function settlePendingSource(
1660
1710
  if (visited.has(node) || !removePendingSource(node, source)) return;
1661
1711
  visited.add(node);
1662
1712
  node._time = clock;
1663
- const remaining = node._pendingSource ?? node._pendingSources?.values().next().value;
1713
+ const remaining = node._pendingSources?.values().next().value;
1664
1714
  if (remaining) {
1665
1715
  setPendingError(node, remaining);
1666
1716
  updateCompanions !== null && updateCompanions(node);
@@ -1908,7 +1958,7 @@ function handleAsync(el, result, setter) {
1908
1958
  return syncValue;
1909
1959
  }
1910
1960
  function clearStatus(el, clearUninitialized = false) {
1911
- if (el._pendingSource || el._pendingSources) clearPendingSources(el);
1961
+ if (el._pendingSources) clearPendingSources(el);
1912
1962
  if (el._blocked) el._blocked = false;
1913
1963
  // The pending window is over; its quiet classification dies with it.
1914
1964
  // (Unconditional: _reask is baked into the node literals, so this is a
@@ -1985,12 +2035,8 @@ function notifyStatus(el, status, error, blockStatus, lane) {
1985
2035
  forEachDependent(el, (sub, link) => {
1986
2036
  sub._time = clock;
1987
2037
  if (
1988
- (status === STATUS_PENDING &&
1989
- pendingSource &&
1990
- sub._pendingSource !== pendingSource &&
1991
- !sub._pendingSources?.has(pendingSource)) ||
1992
- (status !== STATUS_PENDING &&
1993
- (sub._error !== error || sub._pendingSource || sub._pendingSources))
2038
+ (status === STATUS_PENDING && pendingSource && !sub._pendingSources?.has(pendingSource)) ||
2039
+ (status !== STATUS_PENDING && (sub._error !== error || sub._pendingSources))
1994
2040
  ) {
1995
2041
  // A pending-observer link is the subscription an `isPending` read created.
1996
2042
  // It exists so the observer re-runs when the source settles, but it must
@@ -2209,7 +2255,7 @@ function recompute(el, create = false) {
2209
2255
  if (!el._error) {
2210
2256
  trimStaleDeps(el);
2211
2257
  const compareValue = hasOverride
2212
- ? el._overrideValue
2258
+ ? unwrapOverride(el._overrideValue)
2213
2259
  : el._pendingValue === NOT_PENDING
2214
2260
  ? el._value
2215
2261
  : el._pendingValue;
@@ -2246,7 +2292,7 @@ function recompute(el, create = false) {
2246
2292
  // own reveal schedule; drop any superseded older hold so its queued
2247
2293
  // commit can't clobber the fresh value.
2248
2294
  if (hasOverride && isOptimisticDirty) {
2249
- el._overrideValue = value;
2295
+ el._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value;
2250
2296
  el._pendingValue = NOT_PENDING;
2251
2297
  }
2252
2298
  } else {
@@ -2614,22 +2660,12 @@ function read(el) {
2614
2660
  }
2615
2661
  return !c || el._pendingValue === NOT_PENDING ? el._value : el._pendingValue;
2616
2662
  }
2617
- if (strictRead && owner._statusFlags & STATUS_PENDING) {
2618
- const message =
2619
- `[PENDING_ASYNC_UNTRACKED_READ] Reading a pending async value directly in ${strictRead}. ` +
2620
- `Async values must be read within a tracking scope (JSX, a memo, or an effect's compute function).`;
2621
- emitDiagnostic({
2622
- code: "PENDING_ASYNC_UNTRACKED_READ",
2623
- kind: "async",
2624
- severity: "error",
2625
- message,
2663
+ if (strictRead && owner._statusFlags & STATUS_PENDING)
2664
+ throwPendingUntrackedRead(strictRead, {
2626
2665
  ownerId: c?.id,
2627
2666
  ownerName: c?._name,
2628
- nodeName: owner?._name,
2629
- data: { strictRead }
2667
+ nodeName: owner?._name
2630
2668
  });
2631
- throw new Error(message);
2632
- }
2633
2669
  if (c && tracking) {
2634
2670
  link(el, c, pendingCheckActive);
2635
2671
  // Mark inheritance through derivation (see the fast path above), and its
@@ -2715,27 +2751,17 @@ function read(el) {
2715
2751
  return snapshot;
2716
2752
  }
2717
2753
  }
2718
- if (strictRead) {
2719
- const message =
2720
- `[STRICT_READ_UNTRACKED] Reactive value read directly in ${strictRead} will not update. ` +
2721
- `Move it into a tracking scope (JSX, a memo, or an effect's compute function).`;
2722
- emitDiagnostic({
2723
- code: "STRICT_READ_UNTRACKED",
2724
- kind: "strict-read",
2725
- severity: "warn",
2726
- message,
2754
+ if (strictRead)
2755
+ warnStrictReadUntracked(strictRead, {
2727
2756
  ownerId: c?.id,
2728
2757
  ownerName: c?._name,
2729
- nodeName: owner?._name,
2730
- data: { strictRead }
2758
+ nodeName: owner?._name
2731
2759
  });
2732
- console.warn(message);
2733
- }
2734
2760
  if (el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING) {
2735
2761
  // An active override means the engine is installed (A17: the override IS
2736
2762
  // the value for every reader — that check itself stays right here).
2737
2763
  if (c && stale && GlobalQueue._readStashed(el)) return el._value;
2738
- return el._overrideValue;
2764
+ return unwrapOverride(el._overrideValue);
2739
2765
  }
2740
2766
  // Entanglement gate: a reader recomputing under an optimistic lane that reads
2741
2767
  // a pending mid-transition write sees the committed value. Projection-store
@@ -3131,7 +3157,7 @@ let stashedOptimisticReads = null;
3131
3157
  /** The optimistic half of setSignal, fired when `_overrideValue !== undefined`. */
3132
3158
  function optimisticWrite(el, v) {
3133
3159
  const hasOverride = el._overrideValue !== NOT_PENDING;
3134
- const currentValue = hasOverride ? el._overrideValue : el._value;
3160
+ const currentValue = hasOverride ? unwrapOverride(el._overrideValue) : el._value;
3135
3161
  if (typeof v === "function") v = v(currentValue);
3136
3162
  const valueChanged =
3137
3163
  !!(el._statusFlags & STATUS_UNINITIALIZED) || !el._equals || !el._equals(currentValue, v);
@@ -3148,10 +3174,17 @@ function optimisticWrite(el, v) {
3148
3174
  // No revert target is stashed: while the override is active every reader
3149
3175
  // sees it (A17), so authoritative arrivals commit silently into _value and
3150
3176
  // reverting is just dropping the override — _value is already correct.
3151
- else globalQueue._optimisticNodes.push(el);
3177
+ else globalQueue._batch._optimisticNodes.push(el);
3178
+ // Stamp ownership on the node (post-merge, so entangled writers share the
3179
+ // joint root). resolveTransition prefers this over the lane's _transition,
3180
+ // which a shared subscriber can merge across transactions (#2912).
3181
+ el._overrideOwner = activeTransition;
3152
3182
  const lane = getOrCreateLane(el);
3153
3183
  el._optimisticLane = lane;
3154
- el._overrideValue = v;
3184
+ // Literal undefined must not land raw: the slot doubles as the optimistic
3185
+ // brand, and erasing it makes the write invisible and routes follow-up
3186
+ // writes off the optimistic path into permanent commits (#2898).
3187
+ el._overrideValue = v === undefined ? OVERRIDE_UNDEFINED : v;
3155
3188
  GlobalQueue._syncCompanions !== null && GlobalQueue._syncCompanions(el, v);
3156
3189
  el._time = clock;
3157
3190
  insertSubs(el, true);
@@ -3223,8 +3256,10 @@ function resolveOptimisticNodes(nodes) {
3223
3256
  if (!(node._statusFlags & STATUS_PENDING)) node._statusFlags &= ~STATUS_UNINITIALIZED;
3224
3257
  const prevOverride = node._overrideValue;
3225
3258
  node._overrideValue = NOT_PENDING;
3226
- if (prevOverride !== NOT_PENDING && node._value !== prevOverride) insertSubs(node, true);
3259
+ if (prevOverride !== NOT_PENDING && node._value !== unwrapOverride(prevOverride))
3260
+ insertSubs(node, true);
3227
3261
  node._transition = null;
3262
+ node._overrideOwner = null;
3228
3263
  }
3229
3264
  // Settlement checkpoint (#2838): companions caught in this batch (or owned
3230
3265
  // by a node in it) re-derive from committed state, so verdicts survive the
@@ -3349,8 +3384,8 @@ function laneAsyncSettled(el) {
3349
3384
  }
3350
3385
  }
3351
3386
  function trackOptimisticStore(store) {
3352
- // After initTransition, globalQueue._optimisticStores IS activeTransition._optimisticStores (same reference)
3353
- globalQueue._optimisticStores.add(store);
3387
+ // After initTransition, globalQueue._batch IS activeTransition (same reference)
3388
+ globalQueue._batch._optimisticStores.add(store);
3354
3389
  schedule();
3355
3390
  }
3356
3391
  /**
@@ -3420,7 +3455,6 @@ function quietPending(el) {
3420
3455
  for (const source of el._pendingSources) if (!source._reask) return false;
3421
3456
  return true;
3422
3457
  }
3423
- if (el._pendingSource) return el._pendingSource._reask;
3424
3458
  return el._reask;
3425
3459
  }
3426
3460
  function newQuestionInFlight(comp) {
@@ -3450,7 +3484,7 @@ function computePendingState(el) {
3450
3484
  }
3451
3485
  if (el._pendingValue !== NOT_PENDING && !(comp._statusFlags & STATUS_UNINITIALIZED)) {
3452
3486
  if (hasActiveOverride(el))
3453
- return !el._equals || !el._equals(el._pendingValue, el._overrideValue);
3487
+ return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._overrideValue));
3454
3488
  return true;
3455
3489
  }
3456
3490
  return newQuestionInFlight(comp);
@@ -3534,10 +3568,23 @@ function latestRead(el) {
3534
3568
  setLatestReadActive(false);
3535
3569
  const visibleValue =
3536
3570
  el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING
3537
- ? el._overrideValue
3571
+ ? unwrapOverride(el._overrideValue)
3538
3572
  : el._value;
3539
3573
  let value;
3540
3574
  try {
3575
+ // An untracked latest() read has no reading context, so read() never
3576
+ // performs its mid-tick pull — a plain write queued between two latest()
3577
+ // calls left a still-subscribed shadow at its previous speculative value
3578
+ // until the flush (#2922). Mirror the tracked-read pull here: mark the
3579
+ // queued staleness through the graph, then bring the shadow up to date.
3580
+ const queue = queueFor(pendingComputed);
3581
+ if (
3582
+ pendingComputed._height >= queue._min &&
3583
+ !(pendingComputed._flags & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE))
3584
+ ) {
3585
+ markHeap(queue);
3586
+ prepareComputed(pendingComputed, true);
3587
+ }
3541
3588
  value = read(pendingComputed);
3542
3589
  } catch (e) {
3543
3590
  if (e instanceof NotReadyError && (!context || !(el._statusFlags & STATUS_UNINITIALIZED)))
@@ -3554,6 +3601,16 @@ function latestRead(el) {
3554
3601
  return visibleValue;
3555
3602
  }
3556
3603
  }
3604
+ // A shadow recomputed by the pull above (not at creation) holds its fresh
3605
+ // speculative value in _pendingValue; a contextless read() only surfaces
3606
+ // _value. Overrides stay authoritative (A17), and stale readers keep the
3607
+ // other transition's committed view, matching read()'s own selection.
3608
+ if (
3609
+ pendingComputed._pendingValue !== NOT_PENDING &&
3610
+ !hasActiveOverride(pendingComputed) &&
3611
+ !(stale && pendingComputed._transition && activeTransition !== pendingComputed._transition)
3612
+ )
3613
+ return pendingComputed._pendingValue;
3557
3614
  return value;
3558
3615
  }
3559
3616
  /** The isPending()-probe read path, installed as GlobalQueue._pendingCheck. */
@@ -3863,15 +3920,43 @@ function restoreTransition(transition, fn) {
3863
3920
  return result;
3864
3921
  }
3865
3922
  /**
3923
+ * The primitive for mutations: imperative async workflows whose *writes span
3924
+ * an async gap* — optimistic write, server round-trip, reconciling write —
3925
+ * where intermediate state must not leak and failure must revert cleanly
3926
+ * (pair with `createOptimistic` / `createOptimisticStore`).
3927
+ *
3928
+ * Navigation-shaped updates do not need an action. A plain setter call is
3929
+ * enough: reads pull the async, and downstream async computeds hold their
3930
+ * previous values per-node until the new ones are ready (`isPending` /
3931
+ * `latest` expose the in-flight state). Reach for `action` only when writes
3932
+ * happen *after* async work, not merely upstream of it.
3933
+ *
3934
+ * Framework-level actions (router form actions, server actions) are
3935
+ * specializations of this primitive: they are actions in exactly this sense —
3936
+ * the same transactional semantics — with form binding, serialization, and
3937
+ * submission tracking layered on top. The shared name is deliberate.
3938
+ *
3866
3939
  * Wraps a generator function so each invocation runs as a single transaction
3867
3940
  * (a "transition") that batches every signal/store write between yields. The
3868
3941
  * surrounding UI sees one atomic update per yielded step; nothing is committed
3869
3942
  * until the action either completes or the next `yield` resolves.
3870
3943
  *
3871
- * Yield promises (or any awaitable) inside the generator the action waits
3872
- * for each before continuing, but the writes you made beforehand are already
3873
- * visible (or held by `<Loading>` if optimistic). Yield bare values for
3874
- * synchronous batched steps.
3944
+ * `yield` is the transaction-safe suspension point: the action waits for a
3945
+ * yielded promise and re-enters the transaction before running the code after
3946
+ * it. A plain `await` does NOT the runtime has no hook into an async
3947
+ * generator's internal await continuations, so writes to fresh signals
3948
+ * between an `await` and the next `yield` escape the transaction and commit
3949
+ * immediately. `await` is still the ergonomic choice for typed results; just
3950
+ * put a bare `yield` before any writes that follow it:
3951
+ *
3952
+ * ```ts
3953
+ * const saved = await api.createTodo(text); // typed result
3954
+ * yield; // re-enter the transaction before writing
3955
+ * setTodos(t => { ... });
3956
+ * ```
3957
+ *
3958
+ * (For the same reason, don't call `flush()` inside an action body — it
3959
+ * drains the transaction mid-step.)
3875
3960
  *
3876
3961
  * Each call returns a `Promise` that resolves with the generator's return
3877
3962
  * value, or rejects if it throws. Pair with `createOptimistic` /
@@ -3882,10 +3967,11 @@ function restoreTransition(transition, fn) {
3882
3967
  * ```ts
3883
3968
  * const [todos, setTodos] = createOptimisticStore<Todo[]>([]);
3884
3969
  *
3885
- * const addTodo = action(function* (text: string) {
3970
+ * const addTodo = action(async function* (text: string) {
3886
3971
  * const tempId = crypto.randomUUID();
3887
3972
  * setTodos(t => { t.push({ id: tempId, text, pending: true }); }); // optimistic
3888
- * const saved = yield api.createTodo(text); // network round-trip
3973
+ * const saved = await api.createTodo(text); // network round-trip, typed
3974
+ * yield; // re-enter the transaction
3889
3975
  * setTodos(t => {
3890
3976
  * const i = t.findIndex(x => x.id === tempId);
3891
3977
  * if (i >= 0) t[i] = saved;
@@ -3948,11 +4034,33 @@ function action(genFn) {
3948
4034
  };
3949
4035
  const run = r => {
3950
4036
  if (r.done) return done(r.value);
3951
- if (isThenable(r.value))
3952
- return void r.value.then(
3953
- v => restoreTransition(ctx, () => step(v)),
3954
- e => restoreTransition(ctx, () => step(e, true))
3955
- );
4037
+ // Thenable assimilation can itself throw synchronously (a `then`
4038
+ // getter, or a `then()` method that throws — #2918). Match `await`
4039
+ // semantics: the failure is thrown back into the generator at the
4040
+ // yield point (catchable there); if uncaught, step()'s guard settles
4041
+ // the action so its iterator never leaks in the transition. The
4042
+ // settled flag implements A+ 2.3.3.3.4.1: a throw after the thenable
4043
+ // already called a callback is ignored.
4044
+ let settled = false;
4045
+ try {
4046
+ if (isThenable(r.value))
4047
+ return void r.value.then(
4048
+ v => {
4049
+ if (settled) return;
4050
+ settled = true;
4051
+ restoreTransition(ctx, () => step(v));
4052
+ },
4053
+ e => {
4054
+ if (settled) return;
4055
+ settled = true;
4056
+ restoreTransition(ctx, () => step(e, true));
4057
+ }
4058
+ );
4059
+ } catch (e) {
4060
+ if (settled) return;
4061
+ settled = true;
4062
+ return void restoreTransition(ctx, () => step(e, true));
4063
+ }
3956
4064
  restoreTransition(ctx, () => step(r.value));
3957
4065
  };
3958
4066
  step();
@@ -4428,6 +4536,8 @@ function addEnumSymbols(o, syms, keys) {
4428
4536
  }
4429
4537
  }
4430
4538
  function getAllKeys(value, override, next) {
4539
+ // Symbols are merged explicitly below; keep the common string-key path on
4540
+ // Object.keys() and avoid reflecting the base symbols twice.
4431
4541
  const keys = getKeys(value, override);
4432
4542
  const nextKeys = Object.keys(next);
4433
4543
  // `value` can be a wrapped store (store-in-store) whose ownKeys trap tracks;
@@ -4510,6 +4620,43 @@ function applyArrayItem(next, previous, target, node, keyFn) {
4510
4620
  applyState(next, wrapped, keyFn);
4511
4621
  } else node && setSignal(node, wrapValue(next, target));
4512
4622
  }
4623
+ /**
4624
+ * The captured-proxy half of the object diff (#2902): descend into keyed-
4625
+ * matching children that have NO node at this level but shelter subscribers
4626
+ * somewhere below (their target's sticky `STORE_DESC` flag, bubbled up by
4627
+ * `getNode`). Without this, a proxy captured through untracked reads — a
4628
+ * `<For>` row handed to a child component — detaches from the diff the
4629
+ * moment no intermediate level happens to be tracked, and its live
4630
+ * subscribers go permanently stale. Never-subscribed branches have no flag
4631
+ * and stay pruned exactly as before; keys the main loop already visited
4632
+ * (node present) are skipped. Callers gate on the parent's own flag and on
4633
+ * `$TRACK` absence (an enumeration-tracked record already diffs every key).
4634
+ */
4635
+ function applyDescendants(previous, next, target, nodes, keyFn, override, optOverride) {
4636
+ const lookup = target[STORE_LOOKUP] || storeLookup;
4637
+ const keys = (override ? getKeys(previous, override) : Object.keys(previous)).concat(
4638
+ getStoreSymbols(previous, override)
4639
+ );
4640
+ for (let i = 0, len = keys.length; i < len; i++) {
4641
+ const key = keys[i];
4642
+ if (nodes?.[key]) continue; // main loop already diffed this slot
4643
+ const previousValue = unwrap(
4644
+ override ? getOverrideValue(previous, override, key, optOverride) : previous[key]
4645
+ );
4646
+ if (!isWrappable(previousValue)) continue;
4647
+ const childTarget = (lookup.get(previousValue) ?? storeLookup.get(previousValue))?.[$TARGET];
4648
+ if (!childTarget?.[STORE_DESC]) continue;
4649
+ const nextValue = unwrap(next[key]);
4650
+ if (
4651
+ previousValue === nextValue ||
4652
+ !isWrappable(nextValue) ||
4653
+ Array.isArray(previousValue) !== Array.isArray(nextValue) ||
4654
+ (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue))
4655
+ )
4656
+ continue;
4657
+ applyState(nextValue, wrap(previousValue, target), keyFn);
4658
+ }
4659
+ }
4513
4660
  // Dispatcher: every applyState call (including recursion) checks for the
4514
4661
  // presence of override / optimistic-override slots once and routes to the
4515
4662
  // appropriate body. The fast body never calls `getOverrideValue` and never
@@ -4619,8 +4766,9 @@ function applyStateFast(next, target, keyFn) {
4619
4766
  }
4620
4767
  // values
4621
4768
  let nodes = target[STORE_NODE];
4769
+ let tracked;
4622
4770
  if (nodes) {
4623
- const tracked = nodes[$TRACK];
4771
+ tracked = nodes[$TRACK];
4624
4772
  const keys = tracked ? getAllKeys(previous, undefined, next) : nodeKeys(nodes);
4625
4773
  for (let i = 0, len = keys.length; i < len; i++) {
4626
4774
  const key = keys[i];
@@ -4640,6 +4788,7 @@ function applyStateFast(next, target, keyFn) {
4640
4788
  } else applyState(nextValue, wrap(previousValue, target), keyFn);
4641
4789
  }
4642
4790
  }
4791
+ if (!tracked && target[STORE_DESC]) applyDescendants(previous, next, target, nodes, keyFn);
4643
4792
  // has
4644
4793
  if ((nodes = target[STORE_HAS])) {
4645
4794
  const keys = nodeKeys(nodes);
@@ -4753,8 +4902,9 @@ function applyStateSlow(next, target, keyFn) {
4753
4902
  return;
4754
4903
  }
4755
4904
  // values
4905
+ let tracked;
4756
4906
  if (nodes) {
4757
- const tracked = nodes[$TRACK];
4907
+ tracked = nodes[$TRACK];
4758
4908
  const keys = tracked ? getAllKeys(previous, override, next) : nodeKeys(nodes);
4759
4909
  for (let i = 0, len = keys.length; i < len; i++) {
4760
4910
  const key = keys[i];
@@ -4774,6 +4924,8 @@ function applyStateSlow(next, target, keyFn) {
4774
4924
  } else applyState(nextValue, wrap(previousValue, target), keyFn);
4775
4925
  }
4776
4926
  }
4927
+ if (!tracked && target[STORE_DESC])
4928
+ applyDescendants(previous, next, target, nodes, keyFn, override, optOverride);
4777
4929
  // has
4778
4930
  if ((nodes = target[STORE_HAS])) {
4779
4931
  const keys = nodeKeys(nodes);
@@ -5005,7 +5157,10 @@ const STORE_VALUE = "v",
5005
5157
  STORE_WRAP = "w",
5006
5158
  STORE_LOOKUP = "l",
5007
5159
  STORE_FIREWALL = "f",
5008
- STORE_OPTIMISTIC = "p";
5160
+ STORE_OPTIMISTIC = "p",
5161
+ STORE_OPTIMISTIC_OWNERS = "t",
5162
+ STORE_PARENT = "u",
5163
+ STORE_DESC = "d";
5009
5164
  const STORE_SELF_PENDING = Symbol("STORE_SELF_PENDING");
5010
5165
  function createStoreProxy(value, traps = storeTraps, extend) {
5011
5166
  let newTarget;
@@ -5028,9 +5183,17 @@ const storeLookup = new WeakMap();
5028
5183
  // Lets reconcile enumerate symbols only for records that need it (#2851).
5029
5184
  const symbolKeyedRecords = new WeakSet();
5030
5185
  function wrap(value, target) {
5031
- if (target?.[STORE_WRAP]) return target[STORE_WRAP](value, target);
5186
+ if (target?.[STORE_WRAP]) {
5187
+ const p = target[STORE_WRAP](value, target);
5188
+ const t = p[$TARGET];
5189
+ if (t && !t[STORE_PARENT] && t !== target) t[STORE_PARENT] = target;
5190
+ return p;
5191
+ }
5032
5192
  let p = value[$PROXY] || storeLookup.get(value);
5033
- if (!p) storeLookup.set(value, (p = createStoreProxy(value)));
5193
+ if (!p) {
5194
+ storeLookup.set(value, (p = createStoreProxy(value)));
5195
+ if (target) p[$TARGET][STORE_PARENT] = target;
5196
+ }
5034
5197
  return p;
5035
5198
  }
5036
5199
  function isWrappable(obj) {
@@ -5058,7 +5221,7 @@ function unwrapStoreValue(value, map, lookup) {
5058
5221
  const result = isArray ? [] : Object.create(Object.getPrototypeOf(source));
5059
5222
  map.set(value, result);
5060
5223
  lookup = target[STORE_LOOKUP] ?? storeLookup;
5061
- for (const key of getKeys(source, override)) {
5224
+ for (const key of getStoreKeys(source, override)) {
5062
5225
  if (isArray && key === "length") continue;
5063
5226
  const next = key in override ? override[key] : source[key];
5064
5227
  if (next !== $DELETED) result[key] = unwrapStoreValue(next, map, lookup);
@@ -5073,6 +5236,21 @@ function isPrototypePollutionKey$1(property) {
5073
5236
  function ownEnumerableKeys(o) {
5074
5237
  return Reflect.ownKeys(o).filter(k => Object.prototype.propertyIsEnumerable.call(o, k));
5075
5238
  }
5239
+ function ownEnumerableSymbols(o) {
5240
+ const symbols = Object.getOwnPropertySymbols(o);
5241
+ const result = [];
5242
+ for (let i = 0, len = symbols.length; i < len; i++) {
5243
+ const symbol = symbols[i];
5244
+ if (Object.prototype.propertyIsEnumerable.call(o, symbol)) result.push(symbol);
5245
+ }
5246
+ return result;
5247
+ }
5248
+ // Plain-object variant that keeps Object.keys() as the fast path and only pays
5249
+ // descriptor checks for symbols. Do not use this on store proxies: splitting
5250
+ // strings/symbols would invoke their ownKeys trap twice.
5251
+ function ownEnumerableKeysPlain(o) {
5252
+ return Object.keys(o).concat(ownEnumerableSymbols(o));
5253
+ }
5076
5254
  /**
5077
5255
  * Single chokepoint for the store's layered value resolution: returns the
5078
5256
  * override layer (optimistic first, then regular) that shadows `property`, or
@@ -5093,7 +5271,7 @@ function getOverlayLayer(target, property) {
5093
5271
  */
5094
5272
  function visibleNodeValue(node) {
5095
5273
  return node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING
5096
- ? node._overrideValue
5274
+ ? unwrapOverride(node._overrideValue)
5097
5275
  : node._pendingValue !== NOT_PENDING
5098
5276
  ? node._pendingValue
5099
5277
  : node._value;
@@ -5161,22 +5339,37 @@ function getNode(target, nodes, property, value, equals = isEqual, snapshotProps
5161
5339
  }
5162
5340
  if (typeof property === "symbol" && property !== $TRACK && property !== $AFFECTS)
5163
5341
  symbolKeyedRecords.add(nodes);
5164
- // A node born inside a live keyless mark's identity scope inherits the mark
5342
+ // A node born inside a live mark's identity scope inherits the mark
5165
5343
  // (the declaration walk could only cover nodes that existed then). The
5166
5344
  // record's own $AFFECTS carrier is the mark's channel, never a member.
5167
- if (property !== $AFFECTS && affectsScopes.size) inheritAffectsMarks(s, target[STORE_VALUE]);
5345
+ if (property !== $AFFECTS && affectsScopes.size)
5346
+ inheritAffectsMarks(s, target[STORE_VALUE], property);
5347
+ // Node presence bubbles up the wrap chain (sticky), so reconcile can see
5348
+ // "subscribers live somewhere below" through node-less intermediate
5349
+ // records — the captured-proxy diff gate (#2902). Amortized O(1): stops at
5350
+ // the first already-flagged ancestor.
5351
+ let t = target;
5352
+ while (t && !t[STORE_DESC]) {
5353
+ t[STORE_DESC] = true;
5354
+ t = t[STORE_PARENT];
5355
+ }
5168
5356
  return (nodes[property] = s);
5169
5357
  }
5170
5358
  /**
5171
- * Scope inheritance for late-created nodes: every live keyless mark whose
5172
- * identity scope contains the owning record's raw gets counted on the new
5173
- * node. Inherited marks live exactly as long as the scope's carrier the
5174
- * release hook below drops them with the entry.
5359
+ * Scope inheritance for late-created nodes: every live mark whose identity
5360
+ * scope contains the owning record's raw and, for keyed marks, whose key
5361
+ * is this property gets counted on the new node. Inherited marks live
5362
+ * exactly as long as the scope's carrier — the release hook below drops
5363
+ * them with the entry.
5175
5364
  */
5176
- function inheritAffectsMarks(node, raw) {
5365
+ function inheritAffectsMarks(node, raw, property) {
5177
5366
  // A live scope exists, so affects.ts already installed the mark engine.
5178
5367
  for (const [carrier, entry] of affectsScopes) {
5179
- if (carrier._affectsCount && entry.scope.has(raw)) {
5368
+ if (
5369
+ carrier._affectsCount &&
5370
+ entry.scope.has(raw) &&
5371
+ (entry.key === undefined || entry.key === property)
5372
+ ) {
5180
5373
  GlobalQueue._markAffects(node);
5181
5374
  entry.inherited.push(node);
5182
5375
  }
@@ -5214,7 +5407,10 @@ function walkAffectsScope(
5214
5407
  collectRecordNodes(target[STORE_NODE], found);
5215
5408
  collectRecordNodes(target[STORE_HAS], found);
5216
5409
  override = mergedOverlay(target);
5217
- lookup = target[STORE_LOOKUP] ?? lookup;
5410
+ // Carry the effective lookup into untouched descendants. Default stores
5411
+ // use the global lookup just like snapshotImpl; without it, nested raw
5412
+ // objects fall back to string-only enumeration and symbol branches vanish.
5413
+ lookup = target[STORE_LOOKUP] ?? lookup ?? storeLookup;
5218
5414
  }
5219
5415
  if (Array.isArray(raw)) {
5220
5416
  const len = override?.length ?? raw.length;
@@ -5222,8 +5418,18 @@ function walkAffectsScope(
5222
5418
  const v = override && i in override ? override[i] : raw[i];
5223
5419
  if (v !== $DELETED) walkAffectsScope(v, entry, found, lookup, visited);
5224
5420
  }
5421
+ // Arrays can also carry symbol metadata. Enumerate symbols separately to
5422
+ // avoid scanning large index lists twice. Outside a store tree, retain the
5423
+ // existing index-only walk.
5424
+ const symbols = target || lookup ? getStoreSymbols(raw, override) : [];
5425
+ for (let i = 0, l = symbols.length; i < l; i++) {
5426
+ const key = symbols[i];
5427
+ const desc = getPropertyDescriptor(raw, override, key);
5428
+ if (!desc || desc.get) continue;
5429
+ walkAffectsScope(desc.value, entry, found, lookup, visited);
5430
+ }
5225
5431
  } else {
5226
- const keys = getKeys(raw, override);
5432
+ const keys = target || lookup ? getStoreKeys(raw, override) : getKeys(raw, override);
5227
5433
  for (let i = 0, l = keys.length; i < l; i++) {
5228
5434
  const desc = getPropertyDescriptor(raw, override, keys[i]);
5229
5435
  if (!desc || desc.get) continue;
@@ -5252,7 +5458,7 @@ function collectRecordNodes(nodes, found) {
5252
5458
  *
5253
5459
  * @internal
5254
5460
  */
5255
- function witnessAffectsMark(target) {
5461
+ function witnessAffectsMark(target, property) {
5256
5462
  // Callers guard on `pendingCheckActive`, which only flips inside
5257
5463
  // isPending() — the verdict layer is loaded and its hook installed.
5258
5464
  const own = target[STORE_NODE]?.[$AFFECTS];
@@ -5260,7 +5466,12 @@ function witnessAffectsMark(target) {
5260
5466
  if (affectsScopes.size) {
5261
5467
  const raw = target[STORE_VALUE];
5262
5468
  for (const [carrier, entry] of affectsScopes) {
5263
- if (carrier !== own && carrier._affectsCount && entry.scope.has(raw))
5469
+ if (
5470
+ carrier !== own &&
5471
+ carrier._affectsCount &&
5472
+ entry.scope.has(raw) &&
5473
+ (entry.key === undefined || entry.key === property)
5474
+ )
5264
5475
  GlobalQueue._witnessAffects(carrier);
5265
5476
  }
5266
5477
  }
@@ -5278,26 +5489,42 @@ function witnessAffectsMark(target) {
5278
5489
  */
5279
5490
  function getStoreAffectsNodes(target, key) {
5280
5491
  const nodes = getNodes(target, STORE_NODE);
5492
+ GlobalQueue._releaseAffectsScope ||= node => {
5493
+ const entry = affectsScopes.get(node);
5494
+ if (!entry) return;
5495
+ affectsScopes.delete(node);
5496
+ for (let i = 0; i < entry.inherited.length; i++)
5497
+ GlobalQueue._releaseAffectsMark(entry.inherited[i]);
5498
+ };
5281
5499
  if (key === undefined) {
5282
5500
  const carrier = getNode(target, nodes, $AFFECTS, undefined, false);
5283
- GlobalQueue._releaseAffectsScope ||= node => {
5284
- const entry = affectsScopes.get(node);
5285
- if (!entry) return;
5286
- affectsScopes.delete(node);
5287
- for (let i = 0; i < entry.inherited.length; i++)
5288
- GlobalQueue._releaseAffectsMark(entry.inherited[i]);
5289
- };
5290
5501
  let entry = affectsScopes.get(carrier);
5291
5502
  if (!entry) affectsScopes.set(carrier, (entry = { scope: new Set(), inherited: [] }));
5292
5503
  const result = [carrier];
5293
5504
  walkAffectsScope(target[$PROXY], entry, result, target[STORE_LOOKUP], new Set());
5294
5505
  return result;
5295
5506
  }
5296
- if (nodes[key]) return [nodes[key]];
5297
- const layer = getOverlayLayer(target, key);
5298
- const raw = layer ? layer[key] : target[STORE_VALUE][key];
5299
- const prev = raw === $DELETED ? undefined : raw;
5300
- return [upsertStoreNode(target, nodes, key, prev, target[STORE_SNAPSHOT_PROPS])];
5507
+ let node = nodes[key];
5508
+ if (!node) {
5509
+ const layer = getOverlayLayer(target, key);
5510
+ const raw = layer ? layer[key] : target[STORE_VALUE][key];
5511
+ node = upsertStoreNode(
5512
+ target,
5513
+ nodes,
5514
+ key,
5515
+ raw === $DELETED ? undefined : raw,
5516
+ target[STORE_SNAPSHOT_PROPS]
5517
+ );
5518
+ }
5519
+ // Keyed marks resolve by identity too (#2904): another store family's
5520
+ // proxy can share this record's raw (a derived store swaps its backing to
5521
+ // the source's raw when its projection lands), and reads through it never
5522
+ // touch this target's node map. Scope is exactly the owning record's raw,
5523
+ // narrowed to this key for witness and birth inheritance.
5524
+ let entry = affectsScopes.get(node);
5525
+ if (!entry) affectsScopes.set(node, (entry = { scope: new Set(), inherited: [], key }));
5526
+ entry.scope.add(target[STORE_VALUE]);
5527
+ return [node];
5301
5528
  }
5302
5529
  function trackSelf(target, symbol = $TRACK) {
5303
5530
  if (!getObserver()) return;
@@ -5336,17 +5563,44 @@ function mergedOverlay(target) {
5336
5563
  const opt = target[STORE_OPTIMISTIC_OVERRIDE];
5337
5564
  return override && opt ? { ...override, ...opt } : (opt ?? override);
5338
5565
  }
5339
- function getKeys(source, override, enumerable = true) {
5566
+ function getKeysImpl(source, override, enumerable, symbols) {
5340
5567
  // Plain objects can't trigger proxy traps — only pay for the untrack
5341
5568
  // closure when the source is itself a wrapped store (store-in-store).
5342
5569
  const baseKeys = source[$TARGET]
5343
- ? untrack(() => (enumerable ? Object.keys(source) : Reflect.ownKeys(source)))
5570
+ ? untrack(() =>
5571
+ enumerable
5572
+ ? symbols
5573
+ ? ownEnumerableKeys(source)
5574
+ : Object.keys(source)
5575
+ : Reflect.ownKeys(source)
5576
+ )
5344
5577
  : enumerable
5345
- ? Object.keys(source)
5578
+ ? symbols
5579
+ ? ownEnumerableKeysPlain(source)
5580
+ : Object.keys(source)
5346
5581
  : Reflect.ownKeys(source);
5347
- if (!override) return baseKeys;
5582
+ return override ? mergeOverrideKeys(baseKeys, override) : baseKeys;
5583
+ }
5584
+ function getKeys(source, override, enumerable = true) {
5585
+ return getKeysImpl(source, override, enumerable, false);
5586
+ }
5587
+ function getStoreKeys(source, override) {
5588
+ return getKeysImpl(source, override, true, true);
5589
+ }
5590
+ function getStoreSymbols(source, override) {
5591
+ const symbols = source[$TARGET]
5592
+ ? untrack(() => ownEnumerableSymbols(source))
5593
+ : ownEnumerableSymbols(source);
5594
+ return override ? mergeOverrideKeys(symbols, override, true) : symbols;
5595
+ }
5596
+ // Shared override-layer merge for key enumeration: adds live override keys,
5597
+ // drops $DELETED ones. `symbolsOnly` scopes the override scan for the
5598
+ // array-metadata passes.
5599
+ function mergeOverrideKeys(baseKeys, override, symbolsOnly) {
5348
5600
  const keys = new Set(baseKeys);
5349
- const overrides = Reflect.ownKeys(override);
5601
+ const overrides = symbolsOnly
5602
+ ? Object.getOwnPropertySymbols(override)
5603
+ : Reflect.ownKeys(override);
5350
5604
  for (const key of overrides) {
5351
5605
  if (override[key] !== $DELETED) keys.add(key);
5352
5606
  else keys.delete(key);
@@ -5412,6 +5666,19 @@ function armOptimisticStoreWrite(target, store) {
5412
5666
  GlobalQueue._trackOptimisticStore(store);
5413
5667
  }
5414
5668
  }
5669
+ /**
5670
+ * Records which transition owns an optimistic layer entry (#2899), so a
5671
+ * settling action only consumes its own keys — the layer is store-wide, but
5672
+ * concurrent actions writing disjoint keys must revert independently, exactly
5673
+ * like optimistic signal nodes do via the transition's _optimisticNodes.
5674
+ * `activeTransition` is the write's transaction (action() opens it before the
5675
+ * body runs); null marks an ambient write that clears at plain flush end.
5676
+ * Same-key writes across actions keep last-write-wins layer semantics.
5677
+ */
5678
+ function stampOptimisticOwner(target, overrideKey, property) {
5679
+ if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
5680
+ (target[STORE_OPTIMISTIC_OWNERS] ??= Object.create(null))[property] = activeTransition;
5681
+ }
5415
5682
  function upsertStoreNode(target, nodes, property, prev, snapshotProps) {
5416
5683
  if (nodes[property]) return nodes[property];
5417
5684
  const initial = isWrappable(prev) ? wrap(prev, target) : prev;
@@ -5456,12 +5723,26 @@ function notifyStoreProperty(target, property, mode, value, prev, prevHas) {
5456
5723
  notifySelf(target);
5457
5724
  }
5458
5725
  let Writing = null;
5726
+ /**
5727
+ * A derived store's seed is a draft for the derive function, never an
5728
+ * observable value (#2897): until the firewall first resolves there is
5729
+ * nothing to read, so every consumer path throws NotReady — tracked reads
5730
+ * through their node (core read()), and the untracked fall-throughs in the
5731
+ * traps through this guard. Returning the seed leaked it; returning
5732
+ * `undefined` would break non-nullable types. Callers exempt the firewall
5733
+ * itself (the derive function works its own draft while uninitialized).
5734
+ */
5735
+ function throwIfUninitialized(target) {
5736
+ const firewall = target[STORE_FIREWALL];
5737
+ if (firewall && firewall._statusFlags & STATUS_UNINITIALIZED)
5738
+ throw firewall._error ?? new NotReadyError(firewall);
5739
+ }
5459
5740
  const storeTraps = {
5460
5741
  get(target, property, receiver) {
5461
5742
  if (property === $TARGET) return target;
5462
5743
  if (property === $PROXY) return receiver;
5463
5744
  if (property === $REFRESH) return target[STORE_FIREWALL];
5464
- if (pendingCheckActive) witnessAffectsMark(target);
5745
+ if (pendingCheckActive) witnessAffectsMark(target, property);
5465
5746
  if (property === $TRACK) {
5466
5747
  trackSelf(target);
5467
5748
  return receiver;
@@ -5542,24 +5823,26 @@ const storeTraps = {
5542
5823
  }
5543
5824
  }
5544
5825
  if (strictRead && typeof property === "string") {
5545
- const message =
5546
- `[STRICT_READ_UNTRACKED] Reactive value read directly in ${strictRead} will not update. ` +
5547
- `Move it into a tracking scope (JSX, a memo, or an effect's compute function).`;
5548
- emitDiagnostic({
5549
- code: "STRICT_READ_UNTRACKED",
5550
- kind: "strict-read",
5551
- severity: "warn",
5552
- message,
5553
- nodeName: String(property),
5554
- data: { strictRead, property: String(property), source: "store" }
5826
+ // Safeguard parity with core read() (#2897): untracked store reads skip
5827
+ // node creation (and with it read()'s PENDING_ASYNC_UNTRACKED_READ
5828
+ // check), so a derived store's in-flight firewall must be consulted
5829
+ // here — otherwise a component-body read of a refetching store silently
5830
+ // returns a value the reader can never observe updating.
5831
+ if ((target[STORE_FIREWALL]?._statusFlags ?? 0) & STATUS_PENDING)
5832
+ throwPendingUntrackedRead(strictRead, { nodeName: property });
5833
+ warnStrictReadUntracked(strictRead, {
5834
+ nodeName: property,
5835
+ data: { strictRead, property, source: "store" }
5555
5836
  });
5556
- console.warn(message);
5557
5837
  }
5838
+ // Untracked fall-through (tracked reads already threw via their node in
5839
+ // read(); the dev strictRead error above wins first for memo parity).
5840
+ if (!selfRead) throwIfUninitialized(target);
5558
5841
  return isWrappable(value) ? wrap(value, target) : value;
5559
5842
  },
5560
5843
  has(target, property) {
5561
5844
  if (property === $PROXY || property === $TRACK || property === "__proto__") return true;
5562
- if (pendingCheckActive) witnessAffectsMark(target);
5845
+ if (pendingCheckActive) witnessAffectsMark(target, property);
5563
5846
  const hasLayer = getOverlayLayer(target, property);
5564
5847
  const has = hasLayer ? hasLayer[property] !== $DELETED : property in target[STORE_VALUE];
5565
5848
  if (writeOnly(target[$PROXY]) || getObserver() === target[STORE_FIREWALL]) return has;
@@ -5574,6 +5857,7 @@ const storeTraps = {
5574
5857
  if (getObserver()) {
5575
5858
  return read(getNode(target, nodes, property, has));
5576
5859
  }
5860
+ throwIfUninitialized(target);
5577
5861
  return has;
5578
5862
  },
5579
5863
  set(target, property, rawValue) {
@@ -5603,12 +5887,18 @@ const storeTraps = {
5603
5887
  const nextLength = isArrayIndexWrite && nextIndex > len ? nextIndex : undefined;
5604
5888
  if (prev === value && nextLength === undefined) return true;
5605
5889
  armOptimisticStoreWrite(target, store);
5606
- if (value !== undefined && value === base && nextLength === undefined)
5890
+ if (value !== undefined && value === base && nextLength === undefined) {
5607
5891
  delete target[overrideKey]?.[property];
5608
- else {
5892
+ if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
5893
+ delete target[STORE_OPTIMISTIC_OWNERS]?.[property];
5894
+ } else {
5609
5895
  const override = target[overrideKey] || (target[overrideKey] = Object.create(null));
5610
5896
  override[property] = value;
5611
- if (nextLength !== undefined) override.length = nextLength;
5897
+ stampOptimisticOwner(target, overrideKey, property);
5898
+ if (nextLength !== undefined) {
5899
+ override.length = nextLength;
5900
+ stampOptimisticOwner(target, overrideKey, "length");
5901
+ }
5612
5902
  }
5613
5903
  notifyStoreProperty(target, property, "set", value, prev, prevHas);
5614
5904
  // Shrinking an array's length must remove the truncated indices, otherwise
@@ -5627,6 +5917,7 @@ const storeTraps = {
5627
5917
  const prevIndex = i in override ? override[i] : state[i];
5628
5918
  if (!(i in override) && !(i in state)) continue;
5629
5919
  override[i] = $DELETED;
5920
+ stampOptimisticOwner(target, overrideKey, i);
5630
5921
  notifyStoreProperty(target, i, "delete", undefined, prevIndex, true);
5631
5922
  }
5632
5923
  }
@@ -5670,6 +5961,7 @@ const storeTraps = {
5670
5961
  property,
5671
5962
  normalizedDescriptor
5672
5963
  );
5964
+ stampOptimisticOwner(target, overrideKey, property);
5673
5965
  notifyStoreProperty(target, property, "invalidate");
5674
5966
  if (true) {
5675
5967
  const next =
@@ -5699,9 +5991,12 @@ const storeTraps = {
5699
5991
  ) {
5700
5992
  armOptimisticStoreWrite(target, target[$PROXY]);
5701
5993
  (target[overrideKey] || (target[overrideKey] = Object.create(null)))[property] = $DELETED;
5994
+ stampOptimisticOwner(target, overrideKey, property);
5702
5995
  } else if (target[overrideKey] && property in target[overrideKey]) {
5703
5996
  armOptimisticStoreWrite(target, target[$PROXY]);
5704
5997
  delete target[overrideKey][property];
5998
+ if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
5999
+ delete target[STORE_OPTIMISTIC_OWNERS]?.[property];
5705
6000
  } else return true;
5706
6001
  notifyStoreProperty(target, property, "delete", undefined, prev, true);
5707
6002
  });
@@ -5710,7 +6005,15 @@ const storeTraps = {
5710
6005
  },
5711
6006
  ownKeys(target) {
5712
6007
  if (pendingCheckActive) witnessAffectsMark(target);
5713
- if (getObserver() !== target[STORE_FIREWALL]) trackSelf(target);
6008
+ if (getObserver() !== target[STORE_FIREWALL]) {
6009
+ trackSelf(target);
6010
+ // trackSelf no-ops untracked, so enumeration of an unresolved derived
6011
+ // store would otherwise leak the seed's structure (#2897). The write
6012
+ // path is exempt (like the get/has traps' writeOnly early returns):
6013
+ // the first landing's reconcile enumerates the store while
6014
+ // STATUS_UNINITIALIZED is still set — it IS the initialization.
6015
+ if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUninitialized(target);
6016
+ }
5714
6017
  // Merge optimistic override with regular override for key enumeration
5715
6018
  let keys = getKeys(target[STORE_VALUE], target[STORE_OVERRIDE], false);
5716
6019
  if (target[STORE_OPTIMISTIC_OVERRIDE]) {
@@ -5852,7 +6155,7 @@ function propagateAffectsMark(node) {
5852
6155
  const sentinel = getAffectsSentinel(node);
5853
6156
  const error = new NotReadyError(sentinel);
5854
6157
  forEachDependent(node, sub => {
5855
- if (sub._pendingSource !== sentinel && !sub._pendingSources?.has(sentinel)) {
6158
+ if (!sub._pendingSources?.has(sentinel)) {
5856
6159
  notifyStatus(sub, STATUS_PENDING, error);
5857
6160
  }
5858
6161
  });
@@ -5895,7 +6198,7 @@ function markAffects(node) {
5895
6198
  /**
5896
6199
  * Registers one `affects()` mark on a node: counts it, records the
5897
6200
  * registration with the current transaction (after initTransition the queue's
5898
- * array aliases the active transition's, mirroring `_optimisticNodes`), and
6201
+ * batch IS the active transition, mirroring `_optimisticNodes`), and
5899
6202
  * propagates STATUS_PENDING downstream on the status rails so everything
5900
6203
  * DERIVED from the marked data reads pending too. Propagation runs on every
5901
6204
  * registration (not just the first): subscribers gained since an earlier
@@ -5903,7 +6206,7 @@ function markAffects(node) {
5903
6206
  */
5904
6207
  function registerAffectsMark(node) {
5905
6208
  markAffects(node);
5906
- globalQueue._affectsNodes.push(node);
6209
+ globalQueue._batch._affectsNodes.push(node);
5907
6210
  propagateAffectsMark(node);
5908
6211
  schedule();
5909
6212
  }
@@ -5946,7 +6249,7 @@ function onlyMarkPending(el) {
5946
6249
  for (const s of sources) if (!s._affectsFor) return false;
5947
6250
  return true;
5948
6251
  }
5949
- return !!el._pendingSource?._affectsFor;
6252
+ return false;
5950
6253
  }
5951
6254
  /**
5952
6255
  * Collect the still-live marked nodes behind a pended owner's sentinel
@@ -5960,11 +6263,7 @@ function onlyMarkPending(el) {
5960
6263
  * `GlobalQueue._collectMarkSources`, gated on `activeAffectsMarks`.
5961
6264
  */
5962
6265
  function collectMarkSources(el, into) {
5963
- const single = el._pendingSource;
5964
- if (single) {
5965
- const marked = single._affectsFor;
5966
- if (marked && marked._affectsCount) into.push(marked);
5967
- } else if (el._pendingSources) {
6266
+ if (el._pendingSources) {
5968
6267
  for (const s of el._pendingSources) {
5969
6268
  const marked = s._affectsFor;
5970
6269
  if (marked && marked._affectsCount) into.push(marked);
@@ -6051,54 +6350,90 @@ function createOptimisticStore(first, second, options) {
6051
6350
  }
6052
6351
  // Clear the optimistic overrides of a settling batch of stores and notify
6053
6352
  // signals. Owns the whole batch (iterate + clear + reschedule) so the
6054
- // scheduler's flush tail carries only a size-guarded hook call.
6055
- function clearOptimisticStores(stores) {
6353
+ // scheduler's flush tail carries only a size-guarded hook call. The
6354
+ // completing transition scopes each clear to its own layer keys (#2899).
6355
+ function clearOptimisticStores(stores, completing) {
6056
6356
  for (const store of stores) {
6057
6357
  const target = store[$TARGET];
6058
- if (target?.[STORE_OPTIMISTIC_OVERRIDE]) clearOptimisticOverride(target);
6358
+ if (target?.[STORE_OPTIMISTIC_OVERRIDE]) clearOptimisticOverride(target, completing);
6059
6359
  }
6060
6360
  stores.clear();
6061
6361
  schedule();
6062
6362
  }
6063
- function clearOptimisticOverride(target) {
6363
+ /**
6364
+ * Consume optimistic layer entries and reset their backing nodes to base.
6365
+ * With `completing` (settle path, #2899) only entries the settling
6366
+ * transaction owns are consumed — the layer is store-wide but concurrent
6367
+ * actions revert independently, so keys stamped by a still-in-flight
6368
+ * transition survive (node-level overrides already have this granularity via
6369
+ * _optimisticNodes; this is the layer's half). `null` consumes ambient
6370
+ * (transaction-less) entries at plain flush end. Omitted (projection landing:
6371
+ * fresh authoritative data) consumes everything — the correction supersedes
6372
+ * every tentative layer.
6373
+ */
6374
+ function clearOptimisticOverride(target, completing) {
6064
6375
  const override = target[STORE_OPTIMISTIC_OVERRIDE];
6065
6376
  if (!override) return;
6066
6377
  const nodes = target[STORE_NODE];
6067
- delete target[STORE_OPTIMISTIC_OVERRIDE];
6068
- // Notify signals for all overridden properties
6378
+ const owners = target[STORE_OPTIMISTIC_OWNERS];
6379
+ const scoped = completing !== undefined;
6380
+ let cleared = false;
6381
+ let remaining = false;
6069
6382
  // Use projectionWriteActive to bypass optimistic signal behavior (no lane creation)
6070
6383
  // This ensures reversion effects go to regular queues, not lane queues
6071
6384
  const wasProjectionWriteActive = projectionWriteActive;
6072
6385
  setProjectionWriteActive(true);
6073
6386
  try {
6074
- if (nodes) {
6075
- for (const key of Reflect.ownKeys(override)) {
6076
- if (nodes[key]) {
6077
- const node = nodes[key];
6078
- // Clear lane association so effects go to regular queue
6079
- node._optimisticLane = undefined;
6080
- // Re-read from base the optimistic layer was deleted above, so the
6081
- // overlay resolves to STORE_OVERRIDE or STORE_VALUE.
6082
- const layer = getOverlayLayer(target, key);
6083
- const baseValue = layer ? layer[key] : target[STORE_VALUE][key];
6084
- const value = baseValue === $DELETED ? undefined : baseValue;
6085
- const next = isWrappable(value) ? wrap(value, target) : value;
6086
- const prev = visibleNodeValue(node);
6087
- node._overrideValue = NOT_PENDING;
6088
- node._pendingValue = NOT_PENDING;
6089
- node._value = next;
6090
- if (!node._equals || !node._equals(prev, next)) {
6091
- insertSubs(node, true);
6092
- schedule();
6387
+ for (const key of Reflect.ownKeys(override)) {
6388
+ if (scoped) {
6389
+ let owner = owners?.[key] ?? null;
6390
+ // Resolve merge chains (entangled actions settle as one); path-compress
6391
+ // so later keys skip the walk. A dead owner (`_done === true`) settled
6392
+ // through some other path — never strand its entry. A null owner is an
6393
+ // ambient write: its batch belongs to whichever transaction adopted it
6394
+ // (initTransition mid-batch) or to the plain flush, so it clears on
6395
+ // whichever clear call reaches this store first.
6396
+ if (owner) {
6397
+ if (typeof owner._done === "object") owner = owners[key] = currentTransition(owner);
6398
+ if (owner !== completing && owner._done !== true) {
6399
+ remaining = true;
6400
+ continue;
6093
6401
  }
6094
6402
  }
6095
6403
  }
6096
- // Notify $TRACK
6097
- if (nodes[$TRACK]) {
6098
- nodes[$TRACK]._optimisticLane = undefined;
6099
- notifySelf(target);
6404
+ delete override[key];
6405
+ if (owners) delete owners[key];
6406
+ cleared = true;
6407
+ const node = nodes?.[key];
6408
+ if (node) {
6409
+ // Clear lane association so effects go to regular queue
6410
+ node._optimisticLane = undefined;
6411
+ // Re-read from base — this key left the optimistic layer above, so the
6412
+ // overlay resolves to STORE_OVERRIDE or STORE_VALUE.
6413
+ const layer = getOverlayLayer(target, key);
6414
+ const baseValue = layer ? layer[key] : target[STORE_VALUE][key];
6415
+ const value = baseValue === $DELETED ? undefined : baseValue;
6416
+ const next = isWrappable(value) ? wrap(value, target) : value;
6417
+ const prev = visibleNodeValue(node);
6418
+ node._overrideValue = NOT_PENDING;
6419
+ node._overrideOwner = null;
6420
+ node._pendingValue = NOT_PENDING;
6421
+ node._value = next;
6422
+ if (!node._equals || !node._equals(prev, next)) {
6423
+ insertSubs(node, true);
6424
+ schedule();
6425
+ }
6100
6426
  }
6101
6427
  }
6428
+ if (!remaining) {
6429
+ delete target[STORE_OPTIMISTIC_OVERRIDE];
6430
+ delete target[STORE_OPTIMISTIC_OWNERS];
6431
+ }
6432
+ // Notify $TRACK
6433
+ if (cleared && nodes?.[$TRACK]) {
6434
+ nodes[$TRACK]._optimisticLane = undefined;
6435
+ notifySelf(target);
6436
+ }
6102
6437
  } finally {
6103
6438
  setProjectionWriteActive(wasProjectionWriteActive);
6104
6439
  }
@@ -6309,6 +6644,21 @@ function snapshotImpl(item, track, map, lookup) {
6309
6644
  result[i] = unwrapped;
6310
6645
  }
6311
6646
  }
6647
+ // Enumerate array symbols separately to avoid scanning indices twice.
6648
+ // Spread copies omit symbols, so assign them after the numeric walk.
6649
+ const symbols = lookup ? getStoreSymbols(item, override) : [];
6650
+ for (let i = 0, l = symbols.length; i < l; i++) {
6651
+ const prop = symbols[i];
6652
+ const desc = getPropertyDescriptor(item, override, prop);
6653
+ if (!desc || desc.get) continue;
6654
+ v = override && prop in override ? override[prop] : item[prop];
6655
+ if (track && isWrappable(v)) wrap(v, target);
6656
+ unwrapped = snapshotImpl(v, track, map, lookup);
6657
+ if (unwrapped !== v || result) {
6658
+ if (!result) map.set(item, (result = Object.assign([...item], item)));
6659
+ result[prop] = unwrapped;
6660
+ }
6661
+ }
6312
6662
  // Deleted trailing slots are skipped above, so restore length to preserve
6313
6663
  // holes instead of truncating the copy (#2846) — mirrors unwrapStoreValue.
6314
6664
  if (result) result.length = len;
@@ -6316,7 +6666,9 @@ function snapshotImpl(item, track, map, lookup) {
6316
6666
  // Specialized walk for the common no-overlay case (from #2756): the own
6317
6667
  // descriptor gives the value directly, so each property is read once with
6318
6668
  // no overlay membership checks.
6319
- const keys = getKeys(item, undefined);
6669
+ // A lookup means this object belongs to an immutable store backing tree,
6670
+ // even if that nested value has not needed its own proxy yet.
6671
+ const keys = lookup ? getStoreKeys(item, undefined) : getKeys(item, undefined);
6320
6672
  for (let i = 0, l = keys.length; i < l; i++) {
6321
6673
  const prop = keys[i];
6322
6674
  const desc = Object.getOwnPropertyDescriptor(item, prop);
@@ -6332,7 +6684,9 @@ function snapshotImpl(item, track, map, lookup) {
6332
6684
  }
6333
6685
  }
6334
6686
  } else {
6335
- const keys = getKeys(item, override);
6687
+ // An override only exists on a store record, and the target branch above
6688
+ // always set `lookup` alongside it — so this branch is always store-keyed.
6689
+ const keys = getStoreKeys(item, override);
6336
6690
  for (let i = 0, l = keys.length; i < l; i++) {
6337
6691
  let prop = keys[i];
6338
6692
  const desc = getPropertyDescriptor(item, override, prop);
@@ -6599,6 +6953,15 @@ function mapArray(list, map, options) {
6599
6953
  return accessor(node);
6600
6954
  }
6601
6955
  const pureOptions = { ownedWrite: true };
6956
+ // Exception safety (#2903): a map callback can throw NotReadyError mid-pass
6957
+ // (async read), and the computed re-runs the whole pass after settle. Every
6958
+ // pass therefore STAGES its work — new rows are created into temp arrays and
6959
+ // removals are deferred — and commits to `this` only after every mapper
6960
+ // succeeded. An aborted pass disposes just the owners it created and leaves
6961
+ // `_items`/`_mappings`/`_nodes`/`_rows`/`_indexes`/`_len` exactly as they
6962
+ // were, so the retry diffs against uncorrupted state. Consequence of the
6963
+ // strong-abort ordering: removed rows now dispose AFTER the pass's new rows
6964
+ // are created (you cannot destroy state before knowing the pass will land).
6602
6965
  function updateKeyedMap() {
6603
6966
  const newItems = this._list() || [],
6604
6967
  newLen = newItems.length;
@@ -6606,25 +6969,26 @@ function updateKeyedMap() {
6606
6969
  runWithOwner(this._owner, () => {
6607
6970
  let i,
6608
6971
  j,
6972
+ rows,
6973
+ indexes,
6974
+ // Mappers write freshly-created row/index signals into the STAGE
6975
+ // arrays (`rows`/`indexes`), never into `this._rows`/`this._indexes`.
6609
6976
  mapper = this._rows
6610
6977
  ? this._byIndex
6611
6978
  ? () => {
6612
- this._rows[j] = signal(newItems[j], pureOptions);
6613
- return this._map(accessor(this._rows[j]), j);
6979
+ rows[j] = signal(newItems[j], pureOptions);
6980
+ return this._map(accessor(rows[j]), j);
6614
6981
  }
6615
6982
  : () => {
6616
- this._rows[j] = signal(newItems[j], pureOptions);
6617
- this._indexes && (this._indexes[j] = signal(j, pureOptions));
6618
- return this._map(
6619
- accessor(this._rows[j]),
6620
- this._indexes ? accessor(this._indexes[j]) : undefined
6621
- );
6983
+ rows[j] = signal(newItems[j], pureOptions);
6984
+ indexes && (indexes[j] = signal(j, pureOptions));
6985
+ return this._map(accessor(rows[j]), indexes ? accessor(indexes[j]) : undefined);
6622
6986
  }
6623
6987
  : this._indexes
6624
6988
  ? () => {
6625
6989
  const item = newItems[j];
6626
- this._indexes[j] = signal(j, pureOptions);
6627
- return this._map(item, accessor(this._indexes[j]));
6990
+ indexes[j] = signal(j, pureOptions);
6991
+ return this._map(item, accessor(indexes[j]));
6628
6992
  }
6629
6993
  : () => {
6630
6994
  const item = newItems[j];
@@ -6642,19 +7006,31 @@ function updateKeyedMap() {
6642
7006
  this._indexes && (this._indexes = []);
6643
7007
  }
6644
7008
  if (this._fallback && !this._mappings[0]) {
6645
- // create fallback
7009
+ // an aborted fallback attempt leaves an owner without a mapping;
7010
+ // dispose it before re-creating
7011
+ this._nodes[0]?.dispose();
6646
7012
  this._mappings[0] = runWithOwner((this._nodes[0] = createOwner()), this._fallback);
6647
7013
  }
6648
7014
  }
6649
7015
  // fast path for new create
6650
7016
  else if (this._len === 0) {
6651
- // dispose previous fallback
6652
- if (this._nodes[0]) this._nodes[0].dispose();
6653
- this._mappings = new Array(newLen);
6654
- for (j = 0; j < newLen; j++) {
6655
- this._items[j] = newItems[j];
6656
- this._mappings[j] = runWithOwner((this._nodes[j] = createOwner()), mapper);
7017
+ const mappings = new Array(newLen);
7018
+ const nodes = new Array(newLen);
7019
+ rows = this._rows && new Array(newLen);
7020
+ indexes = this._indexes && new Array(newLen);
7021
+ try {
7022
+ for (j = 0; j < newLen; j++) mappings[j] = runWithOwner((nodes[j] = createOwner()), mapper);
7023
+ } catch (err) {
7024
+ for (i = 0; i <= j; i++) nodes[i]?.dispose();
7025
+ throw err;
6657
7026
  }
7027
+ // commit
7028
+ if (this._nodes[0]) this._nodes[0].dispose(); // previous fallback
7029
+ this._mappings = mappings;
7030
+ this._nodes = nodes;
7031
+ rows && (this._rows = rows);
7032
+ indexes && (this._indexes = indexes);
7033
+ this._items = newItems.slice(0);
6658
7034
  this._len = newLen;
6659
7035
  } else {
6660
7036
  let start,
@@ -6664,10 +7040,12 @@ function updateKeyedMap() {
6664
7040
  key,
6665
7041
  newIndices,
6666
7042
  newIndicesNext,
7043
+ removed,
7044
+ created,
6667
7045
  temp = new Array(newLen),
6668
- tempNodes = new Array(newLen),
6669
- tempRows = this._rows ? new Array(newLen) : undefined,
6670
- tempIndexes = this._indexes ? new Array(newLen) : undefined;
7046
+ tempNodes = new Array(newLen);
7047
+ rows = this._rows ? new Array(newLen) : undefined;
7048
+ indexes = this._indexes ? new Array(newLen) : undefined;
6671
7049
  // skip common prefix
6672
7050
  for (
6673
7051
  start = 0, end = Math.min(this._len, newLen);
@@ -6689,8 +7067,8 @@ function updateKeyedMap() {
6689
7067
  ) {
6690
7068
  temp[newEnd] = this._mappings[end];
6691
7069
  tempNodes[newEnd] = this._nodes[end];
6692
- tempRows && (tempRows[newEnd] = this._rows[end]);
6693
- tempIndexes && (tempIndexes[newEnd] = this._indexes[end]);
7070
+ rows && (rows[newEnd] = this._rows[end]);
7071
+ indexes && (indexes[newEnd] = this._indexes[end]);
6694
7072
  }
6695
7073
  // 0) prepare a map of all indices in newItems, scanning backwards so we encounter them in natural order
6696
7074
  newIndices = new Map();
@@ -6702,7 +7080,7 @@ function updateKeyedMap() {
6702
7080
  newIndicesNext[j] = i === undefined ? -1 : i;
6703
7081
  newIndices.set(key, j);
6704
7082
  }
6705
- // 1) step through all old items and see if they can be found in the new set; if so, save them in a temp array and mark them moved; if not, exit them
7083
+ // 1) step through all old items and see if they can be found in the new set; if so, save them in a temp array and mark them moved; if not, queue them for disposal at commit
6706
7084
  for (i = start; i <= end; i++) {
6707
7085
  item = this._items[i];
6708
7086
  key = this._key ? this._key(item) : item;
@@ -6710,32 +7088,40 @@ function updateKeyedMap() {
6710
7088
  if (j !== undefined && j !== -1) {
6711
7089
  temp[j] = this._mappings[i];
6712
7090
  tempNodes[j] = this._nodes[i];
6713
- tempRows && (tempRows[j] = this._rows[i]);
6714
- tempIndexes && (tempIndexes[j] = this._indexes[i]);
7091
+ rows && (rows[j] = this._rows[i]);
7092
+ indexes && (indexes[j] = this._indexes[i]);
6715
7093
  j = newIndicesNext[j];
6716
7094
  newIndices.set(key, j);
6717
- } else this._nodes[i].dispose();
7095
+ } else (removed ??= []).push(this._nodes[i]);
6718
7096
  }
6719
- // 2) set all the new values, pulling from the temp array if copied, otherwise entering the new value
7097
+ // 2) create new rows into the temp arrays; an abort disposes only these
7098
+ try {
7099
+ for (j = start; j < newLen; j++) {
7100
+ if (j in temp) continue;
7101
+ (created ??= []).push((tempNodes[j] = createOwner()));
7102
+ temp[j] = runWithOwner(tempNodes[j], mapper);
7103
+ }
7104
+ } catch (err) {
7105
+ if (created) for (i = 0; i < created.length; i++) created[i].dispose();
7106
+ throw err;
7107
+ }
7108
+ // 3) commit: land positions, then dispose exited rows
6720
7109
  for (j = start; j < newLen; j++) {
6721
- if (j in temp) {
6722
- this._mappings[j] = temp[j];
6723
- this._nodes[j] = tempNodes[j];
6724
- if (tempRows) {
6725
- this._rows[j] = tempRows[j];
6726
- setSignal(this._rows[j], newItems[j]);
6727
- }
6728
- if (tempIndexes) {
6729
- this._indexes[j] = tempIndexes[j];
6730
- setSignal(this._indexes[j], j);
6731
- }
6732
- } else {
6733
- this._mappings[j] = runWithOwner((this._nodes[j] = createOwner()), mapper);
7110
+ this._mappings[j] = temp[j];
7111
+ this._nodes[j] = tempNodes[j];
7112
+ if (rows) {
7113
+ this._rows[j] = rows[j];
7114
+ setSignal(this._rows[j], newItems[j]);
7115
+ }
7116
+ if (indexes) {
7117
+ this._indexes[j] = indexes[j];
7118
+ setSignal(this._indexes[j], j);
6734
7119
  }
6735
7120
  }
6736
- // 3) in case the new set is shorter than the old, set the length of the mapped array
7121
+ if (removed) for (i = 0; i < removed.length; i++) removed[i].dispose();
7122
+ // 4) in case the new set is shorter than the old, set the length of the mapped array
6737
7123
  this._mappings = this._mappings.slice(0, (this._len = newLen));
6738
- // 4) save a copy of the mapped items for the next update
7124
+ // 5) save a copy of the mapped items for the next update
6739
7125
  this._items = newItems.slice(0);
6740
7126
  }
6741
7127
  });
@@ -6767,22 +7153,32 @@ function repeat(count, map, options) {
6767
7153
  }
6768
7154
  }
6769
7155
  : map;
6770
- const node = computed(
6771
- updateRepeat.bind({
6772
- _owner: createOwner(),
6773
- _len: 0,
6774
- _offset: 0,
6775
- _count: count,
6776
- _map: wrappedMap,
6777
- _nodes: [],
6778
- _mappings: [],
6779
- _from: options?.from,
6780
- _fallback: options?.fallback
6781
- })
6782
- );
7156
+ const data = {
7157
+ _owner: createOwner(),
7158
+ _len: 0,
7159
+ _offset: 0,
7160
+ _count: count,
7161
+ _map: wrappedMap,
7162
+ _nodes: [],
7163
+ _mappings: [],
7164
+ _from: options?.from,
7165
+ _fallback: options?.fallback
7166
+ };
7167
+ const node = computed(updateRepeat.bind(data));
7168
+ // Same as mapArray: untracked reads inside the internal owner resolve via
7169
+ // _parentComputed, so async reads in row callbacks register with the node
7170
+ // (pending tracking + post-settle retry) instead of vanishing.
7171
+ data._owner._parentComputed = node;
6783
7172
  node._config &= ~CONFIG_AUTO_DISPOSE;
6784
7173
  return accessor(node);
6785
7174
  }
7175
+ // Same staged-commit discipline as `updateKeyedMap` (#2903): the retained
7176
+ // window overlap is copied into fresh arrays, missing indexes are created
7177
+ // into them, and `this` is only touched — including disposal of rows leaving
7178
+ // the window — after every `_map` call succeeded. A NotReadyError mid-pass
7179
+ // disposes only the owners this pass created and leaves prior state intact
7180
+ // for the post-settle retry. The overlap math also subsumes the previous
7181
+ // disjoint-window/front-clear/end-clear/shift special cases.
6786
7182
  function updateRepeat() {
6787
7183
  const newLen = this._count();
6788
7184
  const from = this._from?.() || 0;
@@ -6793,67 +7189,46 @@ function updateRepeat() {
6793
7189
  this._nodes = [];
6794
7190
  this._mappings = [];
6795
7191
  this._len = 0;
6796
- // Reset offset to match the cleared data. Without this, a subsequent
6797
- // nonzero render with a smaller `from` would enter the end-clear loop
6798
- // with `prevTo = stale_offset + 0` > `to` and dispose `_nodes[-1]`
6799
- // (#2767, repro 2).
7192
+ // Reset offset to match the cleared data (#2767, repro 2).
6800
7193
  this._offset = 0;
6801
7194
  }
6802
7195
  if (this._fallback && !this._mappings[0]) {
6803
- // create fallback
7196
+ // an aborted fallback attempt leaves an owner without a mapping;
7197
+ // dispose it before re-creating
7198
+ this._nodes[0]?.dispose();
6804
7199
  this._mappings[0] = runWithOwner((this._nodes[0] = createOwner()), this._fallback);
6805
7200
  }
6806
7201
  return;
6807
7202
  }
6808
7203
  const to = from + newLen;
6809
7204
  const prevTo = this._offset + this._len;
6810
- // remove fallback
6811
- if (this._len === 0 && this._nodes[0]) this._nodes[0].dispose();
6812
- // Disjoint windows have no rows to retain; replace them wholesale.
6813
- if (from >= prevTo || to <= this._offset) {
6814
- for (let i = 0; i < this._len; i++) this._nodes[i].dispose();
6815
- this._nodes = new Array(newLen);
6816
- this._mappings = new Array(newLen);
6817
- for (let i = 0; i < newLen; i++)
6818
- this._mappings[i] = runWithOwner((this._nodes[i] = createOwner()), () =>
6819
- this._map(from + i)
6820
- );
6821
- this._offset = from;
6822
- this._len = newLen;
6823
- return;
7205
+ // Retained overlap [keepStart, keepEnd) in global indexes; empty when the
7206
+ // windows are disjoint or when coming from empty/fallback.
7207
+ const keepStart = Math.max(from, this._offset);
7208
+ const keepEnd = Math.min(to, prevTo);
7209
+ const mappings = new Array(newLen);
7210
+ const nodes = new Array(newLen);
7211
+ for (let i = keepStart; i < keepEnd; i++) {
7212
+ nodes[i - from] = this._nodes[i - this._offset];
7213
+ mappings[i - from] = this._mappings[i - this._offset];
6824
7214
  }
6825
- // clear the end
6826
- for (let i = to; i < prevTo; i++) this._nodes[i - this._offset].dispose();
6827
- if (this._offset < from) {
6828
- // clear beginning `_nodes` is local-indexed, so the rows leaving the
6829
- // front sit at local positions 0..(from - _offset), clamped to old len.
6830
- const removed = from - this._offset;
6831
- for (let i = 0; i < removed && i < this._len; i++) this._nodes[i].dispose();
6832
- // shift indexes
6833
- this._nodes.splice(0, removed);
6834
- this._mappings.splice(0, removed);
6835
- } else if (this._offset > from) {
6836
- // shift indexes
6837
- let i = prevTo - this._offset - 1;
6838
- let difference = this._offset - from;
6839
- this._nodes.length = this._mappings.length = newLen;
6840
- while (i >= difference) {
6841
- this._nodes[i] = this._nodes[i - difference];
6842
- this._mappings[i] = this._mappings[i - difference];
6843
- i--;
6844
- }
6845
- for (let i = 0; i < difference; i++) {
6846
- this._mappings[i] = runWithOwner((this._nodes[i] = createOwner()), () =>
6847
- this._map(i + from)
6848
- );
7215
+ try {
7216
+ for (let i = from; i < to; i++) {
7217
+ if (i >= keepStart && i < keepEnd) continue;
7218
+ mappings[i - from] = runWithOwner((nodes[i - from] = createOwner()), () => this._map(i));
6849
7219
  }
6850
- }
6851
- for (let i = prevTo; i < to; i++) {
6852
- this._mappings[i - from] = runWithOwner((this._nodes[i - from] = createOwner()), () =>
6853
- this._map(i)
6854
- );
6855
- }
6856
- this._mappings = this._mappings.slice(0, newLen);
7220
+ } catch (err) {
7221
+ for (let i = from; i < to; i++)
7222
+ if ((i < keepStart || i >= keepEnd) && nodes[i - from]) nodes[i - from].dispose();
7223
+ throw err;
7224
+ }
7225
+ // commit: dispose the previous fallback or the rows leaving the window
7226
+ if (this._len === 0) this._nodes[0]?.dispose();
7227
+ else
7228
+ for (let i = this._offset; i < prevTo; i++)
7229
+ if (i < from || i >= to) this._nodes[i - this._offset].dispose();
7230
+ this._mappings = mappings;
7231
+ this._nodes = nodes;
6857
7232
  this._offset = from;
6858
7233
  this._len = newLen;
6859
7234
  });