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

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 (36) hide show
  1. package/dist/dev.js +670 -371
  2. package/dist/node.cjs +1878 -1606
  3. package/dist/prod/affects.js +17 -21
  4. package/dist/prod/core/action.js +19 -6
  5. package/dist/prod/core/async.js +57 -71
  6. package/dist/prod/core/constants.js +15 -1
  7. package/dist/prod/core/core.js +39 -39
  8. package/dist/prod/core/effect.js +14 -14
  9. package/dist/prod/core/graph.js +1 -1
  10. package/dist/prod/core/heap.js +2 -2
  11. package/dist/prod/core/lanes.js +23 -13
  12. package/dist/prod/core/optimistic.js +107 -99
  13. package/dist/prod/core/owner.js +23 -23
  14. package/dist/prod/core/scheduler.js +180 -180
  15. package/dist/prod/core/verdict.js +12 -13
  16. package/dist/prod/map.js +196 -167
  17. package/dist/prod/signals.js +1 -1
  18. package/dist/prod/store/optimistic.js +103 -68
  19. package/dist/prod/store/reconcile.js +80 -46
  20. package/dist/prod/store/store.js +204 -73
  21. package/dist/prod/store/utils.js +61 -42
  22. package/dist/types/core/action.d.ts +19 -6
  23. package/dist/types/core/constants.d.ts +12 -0
  24. package/dist/types/core/dev.d.ts +7 -0
  25. package/dist/types/core/lanes.d.ts +2 -0
  26. package/dist/types/core/scheduler.d.ts +2 -6
  27. package/dist/types/core/types.d.ts +9 -1
  28. package/dist/types/store/store.d.ts +8 -2
  29. package/dist/types-cjs/core/action.d.cts +19 -6
  30. package/dist/types-cjs/core/constants.d.cts +12 -0
  31. package/dist/types-cjs/core/dev.d.cts +7 -0
  32. package/dist/types-cjs/core/lanes.d.cts +2 -0
  33. package/dist/types-cjs/core/scheduler.d.cts +2 -6
  34. package/dist/types-cjs/core/types.d.cts +9 -1
  35. package/dist/types-cjs/store/store.d.cts +8 -2
  36. 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,9 @@ 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.
735
+ currentBatch = this._batch = createBatch();
659
736
  // Run lane effects immediately (before stashing) - lanes with no pending async
660
737
  if (activeLanes.size) {
661
738
  GlobalQueue._runLaneEffects(EFFECT_RENDER);
@@ -680,14 +757,26 @@ class GlobalQueue extends Queue {
680
757
  }
681
758
  return;
682
759
  }
683
- this._pendingNodes !== activeTransition._pendingNodes &&
684
- this._pendingNodes.push(...activeTransition._pendingNodes);
685
- this.restoreQueues(activeTransition._queueStash);
686
- transitions.delete(activeTransition);
687
760
  const completingTransition = activeTransition;
761
+ const batch = this._batch;
762
+ batch !== completingTransition &&
763
+ batch._pendingNodes.push(...completingTransition._pendingNodes);
764
+ this.restoreQueues(completingTransition._queueStash);
765
+ transitions.delete(completingTransition);
688
766
  activeTransition = null;
689
- reassignPendingTransition(this._pendingNodes);
767
+ reassignPendingTransition(batch._pendingNodes);
690
768
  finalizePureQueue(completingTransition);
769
+ if (batch === completingTransition) {
770
+ // Drop the dead Transition wrapper but keep its (drained) containers
771
+ // as the ambient batch — late registrations during finalization live
772
+ // there and must survive to the next flush.
773
+ const fresh = createBatch();
774
+ fresh._pendingNodes = batch._pendingNodes;
775
+ fresh._optimisticNodes = batch._optimisticNodes;
776
+ fresh._affectsNodes = batch._affectsNodes;
777
+ fresh._optimisticStores = batch._optimisticStores;
778
+ currentBatch = this._batch = fresh;
779
+ }
691
780
  } else {
692
781
  if (canUseSimpleSyncFlush(this)) {
693
782
  commitPendingNodes();
@@ -710,12 +799,12 @@ class GlobalQueue extends Queue {
710
799
  this.run(EFFECT_USER);
711
800
  if (true) {
712
801
  devCheckActiveOverrides(n => {
713
- if (this._optimisticNodes.includes(n)) return true;
802
+ if (this._batch._optimisticNodes.includes(n)) return true;
714
803
  if (activeTransition?._optimisticNodes.includes(n)) return true;
715
804
  for (const t of transitions) if (t._optimisticNodes.includes(n)) return true;
716
805
  return false;
717
806
  });
718
- devCensusCompanions(n => n === this._pendingNode || this._pendingNodes.includes(n));
807
+ devCensusCompanions(n => this._batch._pendingNodes.includes(n));
719
808
  }
720
809
  if (
721
810
  true &&
@@ -725,7 +814,7 @@ class GlobalQueue extends Queue {
725
814
  activeLanes.size === 0
726
815
  ) {
727
816
  // Fully drained: no transition-scoped state may survive this point.
728
- devCheckQuiescent(n => n === this._pendingNode || this._pendingNodes.includes(n));
817
+ devCheckQuiescent(n => this._batch._pendingNodes.includes(n));
729
818
  }
730
819
  if (true) DEV$1.hooks.onUpdate?.();
731
820
  } finally {
@@ -756,18 +845,7 @@ class GlobalQueue extends Queue {
756
845
  if (transition && transition === activeTransition) return;
757
846
  if (!transition && activeTransition && activeTransition._time === clock) return;
758
847
  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
- };
848
+ activeTransition = transition ?? createBatch();
771
849
  } else if (transition) {
772
850
  const outgoing = activeTransition;
773
851
  mergeTransitionState(transition, outgoing);
@@ -776,60 +854,36 @@ class GlobalQueue extends Queue {
776
854
  }
777
855
  transitions.add(activeTransition);
778
856
  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];
857
+ const batch = this._batch;
858
+ if (batch !== activeTransition) {
859
+ // Adopt the ambient batch into the transaction, then make the
860
+ // transaction the batch so later registrations land there directly.
861
+ // Pending and optimistic nodes are re-stamped as the transaction's;
862
+ // marks don't hijack the node's _transition — a mark on a plain signal
863
+ // must not entangle unrelated writes to it; the same rule holds one hop
864
+ // downstream: propagation never queues pended subscribers as pending
865
+ // nodes, see propagateAffectsMark, #2893.
866
+ for (let i = 0; i < batch._pendingNodes.length; i++) {
867
+ const node = batch._pendingNodes[i];
787
868
  node._transition = activeTransition;
788
869
  activeTransition._pendingNodes.push(node);
789
870
  }
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];
871
+ for (let i = 0; i < batch._optimisticNodes.length; i++) {
872
+ const node = batch._optimisticNodes[i];
795
873
  node._transition = activeTransition;
796
874
  activeTransition._optimisticNodes.push(node);
797
875
  }
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;
876
+ if (batch._affectsNodes.length) activeTransition._affectsNodes.push(...batch._affectsNodes);
877
+ for (const store of batch._optimisticStores) activeTransition._optimisticStores.add(store);
878
+ currentBatch = this._batch = activeTransition;
809
879
  }
810
880
  for (const lane of activeLanes) {
811
881
  if (!lane._transition) lane._transition = activeTransition;
812
882
  }
813
- if (this._optimisticStores !== activeTransition._optimisticStores) {
814
- for (const store of this._optimisticStores) activeTransition._optimisticStores.add(store);
815
- this._optimisticStores = activeTransition._optimisticStores;
816
- }
817
883
  }
818
884
  }
819
885
  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);
886
+ currentBatch._pendingNodes.push(node);
833
887
  }
834
888
  // Sticky: flips true on the first refresh() ever (the only setter of
835
889
  // REACTIVE_REASK) so the hot notification loop skips the per-subscriber flag
@@ -886,11 +940,7 @@ function commitPendingNode(n) {
886
940
  if (n._pendingSignal || n._latestValueComputed) GlobalQueue._snapCompanions(n);
887
941
  }
888
942
  function commitPendingNodes() {
889
- if (globalQueue._pendingNode !== null) {
890
- commitPendingNode(globalQueue._pendingNode);
891
- globalQueue._pendingNode = null;
892
- }
893
- const pendingNodes = globalQueue._pendingNodes;
943
+ const pendingNodes = currentBatch._pendingNodes;
894
944
  for (let i = 0; i < pendingNodes.length; i++) {
895
945
  commitPendingNode(pendingNodes[i]);
896
946
  }
@@ -906,12 +956,11 @@ function finalizePureQueue(completingTransition = null, incomplete = false) {
906
956
  if (ranHeap) runHeap(dirtyQueue, GlobalQueue._update);
907
957
  if (resolvePending) {
908
958
  if (ranHeap) commitPendingNodes();
959
+ // The settling batch: the completing transaction's, or the ambient one.
960
+ const batch = completingTransition ?? globalQueue._batch;
909
961
  // Optimistic reversion: a non-empty batch means _optimisticWrite ran,
910
962
  // which installed the engine's hooks.
911
- const optimisticNodes = completingTransition
912
- ? completingTransition._optimisticNodes
913
- : globalQueue._optimisticNodes;
914
- if (optimisticNodes.length) GlobalQueue._resolveOptimistic(optimisticNodes);
963
+ if (batch._optimisticNodes.length) GlobalQueue._resolveOptimistic(batch._optimisticNodes);
915
964
  // Replay entanglement: subs recorded by the read-time gate get rescheduled
916
965
  // so they re-run with the now-committed values visible.
917
966
  if (completingTransition && completingTransition._gatedSubs.size) {
@@ -924,17 +973,13 @@ function finalizePureQueue(completingTransition = null, incomplete = false) {
924
973
  // Declared motion ends with the transaction: settle (or plain flush end
925
974
  // for ambient marks) releases each registration's refcount. A non-empty
926
975
  // 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;
976
+ if (batch._affectsNodes.length) GlobalQueue._releaseAffectsMarks(batch._affectsNodes);
934
977
  // A non-empty set means trackOptimisticStore ran, which installed the
935
978
  // 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);
979
+ // core lets esbuild shake it — rollup already folds the null guard). The
980
+ // completing transition scopes the clear to its own layer keys (#2899).
981
+ if (batch._optimisticStores.size)
982
+ GlobalQueue._clearOptimisticStores(batch._optimisticStores, completingTransition);
938
983
  sweepTransientStoreNodes();
939
984
  // Lanes only enter activeLanes through the engine's getOrCreateLane.
940
985
  if (activeLanes.size) GlobalQueue._cleanupLanes(completingTransition);
@@ -968,6 +1013,12 @@ function reassignPendingTransition(pendingNodes) {
968
1013
  }
969
1014
  }
970
1015
  const globalQueue = new GlobalQueue();
1016
+ // Hot-path mirror of `globalQueue._batch`: `queuePendingNode` runs once per
1017
+ // staged write and `commitPendingNodes` once per flush, and the extra
1018
+ // property hop through `_batch` was a measured instruction-count regression
1019
+ // (CodSpeed update1to1, PR #2905). The field stays authoritative for
1020
+ // cross-module readers; every `_batch` assignment updates both.
1021
+ let currentBatch = globalQueue._batch;
971
1022
  function flush(fn) {
972
1023
  if (fn) {
973
1024
  syncDepth++;
@@ -1005,7 +1056,7 @@ function runQueue$1(queue, type) {
1005
1056
  }
1006
1057
  function reporterBlocksSource(reporter, source) {
1007
1058
  if (reporter._flags & (REACTIVE_ZOMBIE | REACTIVE_DISPOSED)) return false;
1008
- if (reporter._pendingSource === source || reporter._pendingSources?.has(source)) return true;
1059
+ if (reporter._pendingSources?.has(source)) return true;
1009
1060
  for (let dep = reporter._deps; dep; dep = dep._nextDep) {
1010
1061
  let current = dep._dep;
1011
1062
  while (current) {
@@ -1585,38 +1636,22 @@ function link(dep, sub, pendingObserver = false) {
1585
1636
  else dep._subs = newLink;
1586
1637
  }
1587
1638
 
1639
+ // The lazily-created Set is the ONE container for pending sources. Its
1640
+ // predecessor — a singular slot promoted to a Set on the second source —
1641
+ // created dual state whose migration invariant was easy to break: a third
1642
+ // overlapping source landed beside the Set and removePendingSource refused
1643
+ // to clear it, stranding the Set members' pending forever (#2893).
1588
1644
  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
- }
1645
+ if (el._pendingSources?.has(source)) return false;
1646
+ (el._pendingSources ??= new Set()).add(source);
1601
1647
  return true;
1602
1648
  }
1603
1649
  function removePendingSource(el, source) {
1604
- if (el._pendingSource) {
1605
- if (el._pendingSource !== source) return false;
1606
- el._pendingSource = undefined;
1607
- return true;
1608
- }
1609
1650
  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
- }
1651
+ if (el._pendingSources.size === 0) el._pendingSources = undefined;
1616
1652
  return true;
1617
1653
  }
1618
1654
  function clearPendingSources(el) {
1619
- el._pendingSource = undefined;
1620
1655
  el._pendingSources?.clear();
1621
1656
  el._pendingSources = undefined;
1622
1657
  }
@@ -1660,7 +1695,7 @@ function settlePendingSource(
1660
1695
  if (visited.has(node) || !removePendingSource(node, source)) return;
1661
1696
  visited.add(node);
1662
1697
  node._time = clock;
1663
- const remaining = node._pendingSource ?? node._pendingSources?.values().next().value;
1698
+ const remaining = node._pendingSources?.values().next().value;
1664
1699
  if (remaining) {
1665
1700
  setPendingError(node, remaining);
1666
1701
  updateCompanions !== null && updateCompanions(node);
@@ -1908,7 +1943,7 @@ function handleAsync(el, result, setter) {
1908
1943
  return syncValue;
1909
1944
  }
1910
1945
  function clearStatus(el, clearUninitialized = false) {
1911
- if (el._pendingSource || el._pendingSources) clearPendingSources(el);
1946
+ if (el._pendingSources) clearPendingSources(el);
1912
1947
  if (el._blocked) el._blocked = false;
1913
1948
  // The pending window is over; its quiet classification dies with it.
1914
1949
  // (Unconditional: _reask is baked into the node literals, so this is a
@@ -1985,12 +2020,8 @@ function notifyStatus(el, status, error, blockStatus, lane) {
1985
2020
  forEachDependent(el, (sub, link) => {
1986
2021
  sub._time = clock;
1987
2022
  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))
2023
+ (status === STATUS_PENDING && pendingSource && !sub._pendingSources?.has(pendingSource)) ||
2024
+ (status !== STATUS_PENDING && (sub._error !== error || sub._pendingSources))
1994
2025
  ) {
1995
2026
  // A pending-observer link is the subscription an `isPending` read created.
1996
2027
  // It exists so the observer re-runs when the source settles, but it must
@@ -2209,7 +2240,7 @@ function recompute(el, create = false) {
2209
2240
  if (!el._error) {
2210
2241
  trimStaleDeps(el);
2211
2242
  const compareValue = hasOverride
2212
- ? el._overrideValue
2243
+ ? unwrapOverride(el._overrideValue)
2213
2244
  : el._pendingValue === NOT_PENDING
2214
2245
  ? el._value
2215
2246
  : el._pendingValue;
@@ -2246,7 +2277,7 @@ function recompute(el, create = false) {
2246
2277
  // own reveal schedule; drop any superseded older hold so its queued
2247
2278
  // commit can't clobber the fresh value.
2248
2279
  if (hasOverride && isOptimisticDirty) {
2249
- el._overrideValue = value;
2280
+ el._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value;
2250
2281
  el._pendingValue = NOT_PENDING;
2251
2282
  }
2252
2283
  } else {
@@ -2614,22 +2645,12 @@ function read(el) {
2614
2645
  }
2615
2646
  return !c || el._pendingValue === NOT_PENDING ? el._value : el._pendingValue;
2616
2647
  }
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,
2648
+ if (strictRead && owner._statusFlags & STATUS_PENDING)
2649
+ throwPendingUntrackedRead(strictRead, {
2626
2650
  ownerId: c?.id,
2627
2651
  ownerName: c?._name,
2628
- nodeName: owner?._name,
2629
- data: { strictRead }
2652
+ nodeName: owner?._name
2630
2653
  });
2631
- throw new Error(message);
2632
- }
2633
2654
  if (c && tracking) {
2634
2655
  link(el, c, pendingCheckActive);
2635
2656
  // Mark inheritance through derivation (see the fast path above), and its
@@ -2715,27 +2736,17 @@ function read(el) {
2715
2736
  return snapshot;
2716
2737
  }
2717
2738
  }
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,
2739
+ if (strictRead)
2740
+ warnStrictReadUntracked(strictRead, {
2727
2741
  ownerId: c?.id,
2728
2742
  ownerName: c?._name,
2729
- nodeName: owner?._name,
2730
- data: { strictRead }
2743
+ nodeName: owner?._name
2731
2744
  });
2732
- console.warn(message);
2733
- }
2734
2745
  if (el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING) {
2735
2746
  // An active override means the engine is installed (A17: the override IS
2736
2747
  // the value for every reader — that check itself stays right here).
2737
2748
  if (c && stale && GlobalQueue._readStashed(el)) return el._value;
2738
- return el._overrideValue;
2749
+ return unwrapOverride(el._overrideValue);
2739
2750
  }
2740
2751
  // Entanglement gate: a reader recomputing under an optimistic lane that reads
2741
2752
  // a pending mid-transition write sees the committed value. Projection-store
@@ -3131,7 +3142,7 @@ let stashedOptimisticReads = null;
3131
3142
  /** The optimistic half of setSignal, fired when `_overrideValue !== undefined`. */
3132
3143
  function optimisticWrite(el, v) {
3133
3144
  const hasOverride = el._overrideValue !== NOT_PENDING;
3134
- const currentValue = hasOverride ? el._overrideValue : el._value;
3145
+ const currentValue = hasOverride ? unwrapOverride(el._overrideValue) : el._value;
3135
3146
  if (typeof v === "function") v = v(currentValue);
3136
3147
  const valueChanged =
3137
3148
  !!(el._statusFlags & STATUS_UNINITIALIZED) || !el._equals || !el._equals(currentValue, v);
@@ -3148,10 +3159,17 @@ function optimisticWrite(el, v) {
3148
3159
  // No revert target is stashed: while the override is active every reader
3149
3160
  // sees it (A17), so authoritative arrivals commit silently into _value and
3150
3161
  // reverting is just dropping the override — _value is already correct.
3151
- else globalQueue._optimisticNodes.push(el);
3162
+ else globalQueue._batch._optimisticNodes.push(el);
3163
+ // Stamp ownership on the node (post-merge, so entangled writers share the
3164
+ // joint root). resolveTransition prefers this over the lane's _transition,
3165
+ // which a shared subscriber can merge across transactions (#2912).
3166
+ el._overrideOwner = activeTransition;
3152
3167
  const lane = getOrCreateLane(el);
3153
3168
  el._optimisticLane = lane;
3154
- el._overrideValue = v;
3169
+ // Literal undefined must not land raw: the slot doubles as the optimistic
3170
+ // brand, and erasing it makes the write invisible and routes follow-up
3171
+ // writes off the optimistic path into permanent commits (#2898).
3172
+ el._overrideValue = v === undefined ? OVERRIDE_UNDEFINED : v;
3155
3173
  GlobalQueue._syncCompanions !== null && GlobalQueue._syncCompanions(el, v);
3156
3174
  el._time = clock;
3157
3175
  insertSubs(el, true);
@@ -3223,8 +3241,10 @@ function resolveOptimisticNodes(nodes) {
3223
3241
  if (!(node._statusFlags & STATUS_PENDING)) node._statusFlags &= ~STATUS_UNINITIALIZED;
3224
3242
  const prevOverride = node._overrideValue;
3225
3243
  node._overrideValue = NOT_PENDING;
3226
- if (prevOverride !== NOT_PENDING && node._value !== prevOverride) insertSubs(node, true);
3244
+ if (prevOverride !== NOT_PENDING && node._value !== unwrapOverride(prevOverride))
3245
+ insertSubs(node, true);
3227
3246
  node._transition = null;
3247
+ node._overrideOwner = null;
3228
3248
  }
3229
3249
  // Settlement checkpoint (#2838): companions caught in this batch (or owned
3230
3250
  // by a node in it) re-derive from committed state, so verdicts survive the
@@ -3349,8 +3369,8 @@ function laneAsyncSettled(el) {
3349
3369
  }
3350
3370
  }
3351
3371
  function trackOptimisticStore(store) {
3352
- // After initTransition, globalQueue._optimisticStores IS activeTransition._optimisticStores (same reference)
3353
- globalQueue._optimisticStores.add(store);
3372
+ // After initTransition, globalQueue._batch IS activeTransition (same reference)
3373
+ globalQueue._batch._optimisticStores.add(store);
3354
3374
  schedule();
3355
3375
  }
3356
3376
  /**
@@ -3420,7 +3440,6 @@ function quietPending(el) {
3420
3440
  for (const source of el._pendingSources) if (!source._reask) return false;
3421
3441
  return true;
3422
3442
  }
3423
- if (el._pendingSource) return el._pendingSource._reask;
3424
3443
  return el._reask;
3425
3444
  }
3426
3445
  function newQuestionInFlight(comp) {
@@ -3450,7 +3469,7 @@ function computePendingState(el) {
3450
3469
  }
3451
3470
  if (el._pendingValue !== NOT_PENDING && !(comp._statusFlags & STATUS_UNINITIALIZED)) {
3452
3471
  if (hasActiveOverride(el))
3453
- return !el._equals || !el._equals(el._pendingValue, el._overrideValue);
3472
+ return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._overrideValue));
3454
3473
  return true;
3455
3474
  }
3456
3475
  return newQuestionInFlight(comp);
@@ -3534,7 +3553,7 @@ function latestRead(el) {
3534
3553
  setLatestReadActive(false);
3535
3554
  const visibleValue =
3536
3555
  el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING
3537
- ? el._overrideValue
3556
+ ? unwrapOverride(el._overrideValue)
3538
3557
  : el._value;
3539
3558
  let value;
3540
3559
  try {
@@ -3868,10 +3887,22 @@ function restoreTransition(transition, fn) {
3868
3887
  * surrounding UI sees one atomic update per yielded step; nothing is committed
3869
3888
  * until the action either completes or the next `yield` resolves.
3870
3889
  *
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.
3890
+ * `yield` is the transaction-safe suspension point: the action waits for a
3891
+ * yielded promise and re-enters the transaction before running the code after
3892
+ * it. A plain `await` does NOT the runtime has no hook into an async
3893
+ * generator's internal await continuations, so writes to fresh signals
3894
+ * between an `await` and the next `yield` escape the transaction and commit
3895
+ * immediately. `await` is still the ergonomic choice for typed results; just
3896
+ * put a bare `yield` before any writes that follow it:
3897
+ *
3898
+ * ```ts
3899
+ * const saved = await api.createTodo(text); // typed result
3900
+ * yield; // re-enter the transaction before writing
3901
+ * setTodos(t => { ... });
3902
+ * ```
3903
+ *
3904
+ * (For the same reason, don't call `flush()` inside an action body — it
3905
+ * drains the transaction mid-step.)
3875
3906
  *
3876
3907
  * Each call returns a `Promise` that resolves with the generator's return
3877
3908
  * value, or rejects if it throws. Pair with `createOptimistic` /
@@ -3882,10 +3913,11 @@ function restoreTransition(transition, fn) {
3882
3913
  * ```ts
3883
3914
  * const [todos, setTodos] = createOptimisticStore<Todo[]>([]);
3884
3915
  *
3885
- * const addTodo = action(function* (text: string) {
3916
+ * const addTodo = action(async function* (text: string) {
3886
3917
  * const tempId = crypto.randomUUID();
3887
3918
  * setTodos(t => { t.push({ id: tempId, text, pending: true }); }); // optimistic
3888
- * const saved = yield api.createTodo(text); // network round-trip
3919
+ * const saved = await api.createTodo(text); // network round-trip, typed
3920
+ * yield; // re-enter the transaction
3889
3921
  * setTodos(t => {
3890
3922
  * const i = t.findIndex(x => x.id === tempId);
3891
3923
  * if (i >= 0) t[i] = saved;
@@ -4428,6 +4460,8 @@ function addEnumSymbols(o, syms, keys) {
4428
4460
  }
4429
4461
  }
4430
4462
  function getAllKeys(value, override, next) {
4463
+ // Symbols are merged explicitly below; keep the common string-key path on
4464
+ // Object.keys() and avoid reflecting the base symbols twice.
4431
4465
  const keys = getKeys(value, override);
4432
4466
  const nextKeys = Object.keys(next);
4433
4467
  // `value` can be a wrapped store (store-in-store) whose ownKeys trap tracks;
@@ -4510,6 +4544,43 @@ function applyArrayItem(next, previous, target, node, keyFn) {
4510
4544
  applyState(next, wrapped, keyFn);
4511
4545
  } else node && setSignal(node, wrapValue(next, target));
4512
4546
  }
4547
+ /**
4548
+ * The captured-proxy half of the object diff (#2902): descend into keyed-
4549
+ * matching children that have NO node at this level but shelter subscribers
4550
+ * somewhere below (their target's sticky `STORE_DESC` flag, bubbled up by
4551
+ * `getNode`). Without this, a proxy captured through untracked reads — a
4552
+ * `<For>` row handed to a child component — detaches from the diff the
4553
+ * moment no intermediate level happens to be tracked, and its live
4554
+ * subscribers go permanently stale. Never-subscribed branches have no flag
4555
+ * and stay pruned exactly as before; keys the main loop already visited
4556
+ * (node present) are skipped. Callers gate on the parent's own flag and on
4557
+ * `$TRACK` absence (an enumeration-tracked record already diffs every key).
4558
+ */
4559
+ function applyDescendants(previous, next, target, nodes, keyFn, override, optOverride) {
4560
+ const lookup = target[STORE_LOOKUP] || storeLookup;
4561
+ const keys = (override ? getKeys(previous, override) : Object.keys(previous)).concat(
4562
+ getStoreSymbols(previous, override)
4563
+ );
4564
+ for (let i = 0, len = keys.length; i < len; i++) {
4565
+ const key = keys[i];
4566
+ if (nodes?.[key]) continue; // main loop already diffed this slot
4567
+ const previousValue = unwrap(
4568
+ override ? getOverrideValue(previous, override, key, optOverride) : previous[key]
4569
+ );
4570
+ if (!isWrappable(previousValue)) continue;
4571
+ const childTarget = (lookup.get(previousValue) ?? storeLookup.get(previousValue))?.[$TARGET];
4572
+ if (!childTarget?.[STORE_DESC]) continue;
4573
+ const nextValue = unwrap(next[key]);
4574
+ if (
4575
+ previousValue === nextValue ||
4576
+ !isWrappable(nextValue) ||
4577
+ Array.isArray(previousValue) !== Array.isArray(nextValue) ||
4578
+ (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue))
4579
+ )
4580
+ continue;
4581
+ applyState(nextValue, wrap(previousValue, target), keyFn);
4582
+ }
4583
+ }
4513
4584
  // Dispatcher: every applyState call (including recursion) checks for the
4514
4585
  // presence of override / optimistic-override slots once and routes to the
4515
4586
  // appropriate body. The fast body never calls `getOverrideValue` and never
@@ -4619,8 +4690,9 @@ function applyStateFast(next, target, keyFn) {
4619
4690
  }
4620
4691
  // values
4621
4692
  let nodes = target[STORE_NODE];
4693
+ let tracked;
4622
4694
  if (nodes) {
4623
- const tracked = nodes[$TRACK];
4695
+ tracked = nodes[$TRACK];
4624
4696
  const keys = tracked ? getAllKeys(previous, undefined, next) : nodeKeys(nodes);
4625
4697
  for (let i = 0, len = keys.length; i < len; i++) {
4626
4698
  const key = keys[i];
@@ -4640,6 +4712,7 @@ function applyStateFast(next, target, keyFn) {
4640
4712
  } else applyState(nextValue, wrap(previousValue, target), keyFn);
4641
4713
  }
4642
4714
  }
4715
+ if (!tracked && target[STORE_DESC]) applyDescendants(previous, next, target, nodes, keyFn);
4643
4716
  // has
4644
4717
  if ((nodes = target[STORE_HAS])) {
4645
4718
  const keys = nodeKeys(nodes);
@@ -4753,8 +4826,9 @@ function applyStateSlow(next, target, keyFn) {
4753
4826
  return;
4754
4827
  }
4755
4828
  // values
4829
+ let tracked;
4756
4830
  if (nodes) {
4757
- const tracked = nodes[$TRACK];
4831
+ tracked = nodes[$TRACK];
4758
4832
  const keys = tracked ? getAllKeys(previous, override, next) : nodeKeys(nodes);
4759
4833
  for (let i = 0, len = keys.length; i < len; i++) {
4760
4834
  const key = keys[i];
@@ -4774,6 +4848,8 @@ function applyStateSlow(next, target, keyFn) {
4774
4848
  } else applyState(nextValue, wrap(previousValue, target), keyFn);
4775
4849
  }
4776
4850
  }
4851
+ if (!tracked && target[STORE_DESC])
4852
+ applyDescendants(previous, next, target, nodes, keyFn, override, optOverride);
4777
4853
  // has
4778
4854
  if ((nodes = target[STORE_HAS])) {
4779
4855
  const keys = nodeKeys(nodes);
@@ -5005,7 +5081,10 @@ const STORE_VALUE = "v",
5005
5081
  STORE_WRAP = "w",
5006
5082
  STORE_LOOKUP = "l",
5007
5083
  STORE_FIREWALL = "f",
5008
- STORE_OPTIMISTIC = "p";
5084
+ STORE_OPTIMISTIC = "p",
5085
+ STORE_OPTIMISTIC_OWNERS = "t",
5086
+ STORE_PARENT = "u",
5087
+ STORE_DESC = "d";
5009
5088
  const STORE_SELF_PENDING = Symbol("STORE_SELF_PENDING");
5010
5089
  function createStoreProxy(value, traps = storeTraps, extend) {
5011
5090
  let newTarget;
@@ -5028,9 +5107,17 @@ const storeLookup = new WeakMap();
5028
5107
  // Lets reconcile enumerate symbols only for records that need it (#2851).
5029
5108
  const symbolKeyedRecords = new WeakSet();
5030
5109
  function wrap(value, target) {
5031
- if (target?.[STORE_WRAP]) return target[STORE_WRAP](value, target);
5110
+ if (target?.[STORE_WRAP]) {
5111
+ const p = target[STORE_WRAP](value, target);
5112
+ const t = p[$TARGET];
5113
+ if (t && !t[STORE_PARENT] && t !== target) t[STORE_PARENT] = target;
5114
+ return p;
5115
+ }
5032
5116
  let p = value[$PROXY] || storeLookup.get(value);
5033
- if (!p) storeLookup.set(value, (p = createStoreProxy(value)));
5117
+ if (!p) {
5118
+ storeLookup.set(value, (p = createStoreProxy(value)));
5119
+ if (target) p[$TARGET][STORE_PARENT] = target;
5120
+ }
5034
5121
  return p;
5035
5122
  }
5036
5123
  function isWrappable(obj) {
@@ -5058,7 +5145,7 @@ function unwrapStoreValue(value, map, lookup) {
5058
5145
  const result = isArray ? [] : Object.create(Object.getPrototypeOf(source));
5059
5146
  map.set(value, result);
5060
5147
  lookup = target[STORE_LOOKUP] ?? storeLookup;
5061
- for (const key of getKeys(source, override)) {
5148
+ for (const key of getStoreKeys(source, override)) {
5062
5149
  if (isArray && key === "length") continue;
5063
5150
  const next = key in override ? override[key] : source[key];
5064
5151
  if (next !== $DELETED) result[key] = unwrapStoreValue(next, map, lookup);
@@ -5073,6 +5160,21 @@ function isPrototypePollutionKey$1(property) {
5073
5160
  function ownEnumerableKeys(o) {
5074
5161
  return Reflect.ownKeys(o).filter(k => Object.prototype.propertyIsEnumerable.call(o, k));
5075
5162
  }
5163
+ function ownEnumerableSymbols(o) {
5164
+ const symbols = Object.getOwnPropertySymbols(o);
5165
+ const result = [];
5166
+ for (let i = 0, len = symbols.length; i < len; i++) {
5167
+ const symbol = symbols[i];
5168
+ if (Object.prototype.propertyIsEnumerable.call(o, symbol)) result.push(symbol);
5169
+ }
5170
+ return result;
5171
+ }
5172
+ // Plain-object variant that keeps Object.keys() as the fast path and only pays
5173
+ // descriptor checks for symbols. Do not use this on store proxies: splitting
5174
+ // strings/symbols would invoke their ownKeys trap twice.
5175
+ function ownEnumerableKeysPlain(o) {
5176
+ return Object.keys(o).concat(ownEnumerableSymbols(o));
5177
+ }
5076
5178
  /**
5077
5179
  * Single chokepoint for the store's layered value resolution: returns the
5078
5180
  * override layer (optimistic first, then regular) that shadows `property`, or
@@ -5093,7 +5195,7 @@ function getOverlayLayer(target, property) {
5093
5195
  */
5094
5196
  function visibleNodeValue(node) {
5095
5197
  return node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING
5096
- ? node._overrideValue
5198
+ ? unwrapOverride(node._overrideValue)
5097
5199
  : node._pendingValue !== NOT_PENDING
5098
5200
  ? node._pendingValue
5099
5201
  : node._value;
@@ -5161,22 +5263,37 @@ function getNode(target, nodes, property, value, equals = isEqual, snapshotProps
5161
5263
  }
5162
5264
  if (typeof property === "symbol" && property !== $TRACK && property !== $AFFECTS)
5163
5265
  symbolKeyedRecords.add(nodes);
5164
- // A node born inside a live keyless mark's identity scope inherits the mark
5266
+ // A node born inside a live mark's identity scope inherits the mark
5165
5267
  // (the declaration walk could only cover nodes that existed then). The
5166
5268
  // record's own $AFFECTS carrier is the mark's channel, never a member.
5167
- if (property !== $AFFECTS && affectsScopes.size) inheritAffectsMarks(s, target[STORE_VALUE]);
5269
+ if (property !== $AFFECTS && affectsScopes.size)
5270
+ inheritAffectsMarks(s, target[STORE_VALUE], property);
5271
+ // Node presence bubbles up the wrap chain (sticky), so reconcile can see
5272
+ // "subscribers live somewhere below" through node-less intermediate
5273
+ // records — the captured-proxy diff gate (#2902). Amortized O(1): stops at
5274
+ // the first already-flagged ancestor.
5275
+ let t = target;
5276
+ while (t && !t[STORE_DESC]) {
5277
+ t[STORE_DESC] = true;
5278
+ t = t[STORE_PARENT];
5279
+ }
5168
5280
  return (nodes[property] = s);
5169
5281
  }
5170
5282
  /**
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.
5283
+ * Scope inheritance for late-created nodes: every live mark whose identity
5284
+ * scope contains the owning record's raw and, for keyed marks, whose key
5285
+ * is this property gets counted on the new node. Inherited marks live
5286
+ * exactly as long as the scope's carrier — the release hook below drops
5287
+ * them with the entry.
5175
5288
  */
5176
- function inheritAffectsMarks(node, raw) {
5289
+ function inheritAffectsMarks(node, raw, property) {
5177
5290
  // A live scope exists, so affects.ts already installed the mark engine.
5178
5291
  for (const [carrier, entry] of affectsScopes) {
5179
- if (carrier._affectsCount && entry.scope.has(raw)) {
5292
+ if (
5293
+ carrier._affectsCount &&
5294
+ entry.scope.has(raw) &&
5295
+ (entry.key === undefined || entry.key === property)
5296
+ ) {
5180
5297
  GlobalQueue._markAffects(node);
5181
5298
  entry.inherited.push(node);
5182
5299
  }
@@ -5214,7 +5331,10 @@ function walkAffectsScope(
5214
5331
  collectRecordNodes(target[STORE_NODE], found);
5215
5332
  collectRecordNodes(target[STORE_HAS], found);
5216
5333
  override = mergedOverlay(target);
5217
- lookup = target[STORE_LOOKUP] ?? lookup;
5334
+ // Carry the effective lookup into untouched descendants. Default stores
5335
+ // use the global lookup just like snapshotImpl; without it, nested raw
5336
+ // objects fall back to string-only enumeration and symbol branches vanish.
5337
+ lookup = target[STORE_LOOKUP] ?? lookup ?? storeLookup;
5218
5338
  }
5219
5339
  if (Array.isArray(raw)) {
5220
5340
  const len = override?.length ?? raw.length;
@@ -5222,8 +5342,18 @@ function walkAffectsScope(
5222
5342
  const v = override && i in override ? override[i] : raw[i];
5223
5343
  if (v !== $DELETED) walkAffectsScope(v, entry, found, lookup, visited);
5224
5344
  }
5345
+ // Arrays can also carry symbol metadata. Enumerate symbols separately to
5346
+ // avoid scanning large index lists twice. Outside a store tree, retain the
5347
+ // existing index-only walk.
5348
+ const symbols = target || lookup ? getStoreSymbols(raw, override) : [];
5349
+ for (let i = 0, l = symbols.length; i < l; i++) {
5350
+ const key = symbols[i];
5351
+ const desc = getPropertyDescriptor(raw, override, key);
5352
+ if (!desc || desc.get) continue;
5353
+ walkAffectsScope(desc.value, entry, found, lookup, visited);
5354
+ }
5225
5355
  } else {
5226
- const keys = getKeys(raw, override);
5356
+ const keys = target || lookup ? getStoreKeys(raw, override) : getKeys(raw, override);
5227
5357
  for (let i = 0, l = keys.length; i < l; i++) {
5228
5358
  const desc = getPropertyDescriptor(raw, override, keys[i]);
5229
5359
  if (!desc || desc.get) continue;
@@ -5252,7 +5382,7 @@ function collectRecordNodes(nodes, found) {
5252
5382
  *
5253
5383
  * @internal
5254
5384
  */
5255
- function witnessAffectsMark(target) {
5385
+ function witnessAffectsMark(target, property) {
5256
5386
  // Callers guard on `pendingCheckActive`, which only flips inside
5257
5387
  // isPending() — the verdict layer is loaded and its hook installed.
5258
5388
  const own = target[STORE_NODE]?.[$AFFECTS];
@@ -5260,7 +5390,12 @@ function witnessAffectsMark(target) {
5260
5390
  if (affectsScopes.size) {
5261
5391
  const raw = target[STORE_VALUE];
5262
5392
  for (const [carrier, entry] of affectsScopes) {
5263
- if (carrier !== own && carrier._affectsCount && entry.scope.has(raw))
5393
+ if (
5394
+ carrier !== own &&
5395
+ carrier._affectsCount &&
5396
+ entry.scope.has(raw) &&
5397
+ (entry.key === undefined || entry.key === property)
5398
+ )
5264
5399
  GlobalQueue._witnessAffects(carrier);
5265
5400
  }
5266
5401
  }
@@ -5278,26 +5413,42 @@ function witnessAffectsMark(target) {
5278
5413
  */
5279
5414
  function getStoreAffectsNodes(target, key) {
5280
5415
  const nodes = getNodes(target, STORE_NODE);
5416
+ GlobalQueue._releaseAffectsScope ||= node => {
5417
+ const entry = affectsScopes.get(node);
5418
+ if (!entry) return;
5419
+ affectsScopes.delete(node);
5420
+ for (let i = 0; i < entry.inherited.length; i++)
5421
+ GlobalQueue._releaseAffectsMark(entry.inherited[i]);
5422
+ };
5281
5423
  if (key === undefined) {
5282
5424
  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
5425
  let entry = affectsScopes.get(carrier);
5291
5426
  if (!entry) affectsScopes.set(carrier, (entry = { scope: new Set(), inherited: [] }));
5292
5427
  const result = [carrier];
5293
5428
  walkAffectsScope(target[$PROXY], entry, result, target[STORE_LOOKUP], new Set());
5294
5429
  return result;
5295
5430
  }
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])];
5431
+ let node = nodes[key];
5432
+ if (!node) {
5433
+ const layer = getOverlayLayer(target, key);
5434
+ const raw = layer ? layer[key] : target[STORE_VALUE][key];
5435
+ node = upsertStoreNode(
5436
+ target,
5437
+ nodes,
5438
+ key,
5439
+ raw === $DELETED ? undefined : raw,
5440
+ target[STORE_SNAPSHOT_PROPS]
5441
+ );
5442
+ }
5443
+ // Keyed marks resolve by identity too (#2904): another store family's
5444
+ // proxy can share this record's raw (a derived store swaps its backing to
5445
+ // the source's raw when its projection lands), and reads through it never
5446
+ // touch this target's node map. Scope is exactly the owning record's raw,
5447
+ // narrowed to this key for witness and birth inheritance.
5448
+ let entry = affectsScopes.get(node);
5449
+ if (!entry) affectsScopes.set(node, (entry = { scope: new Set(), inherited: [], key }));
5450
+ entry.scope.add(target[STORE_VALUE]);
5451
+ return [node];
5301
5452
  }
5302
5453
  function trackSelf(target, symbol = $TRACK) {
5303
5454
  if (!getObserver()) return;
@@ -5336,17 +5487,44 @@ function mergedOverlay(target) {
5336
5487
  const opt = target[STORE_OPTIMISTIC_OVERRIDE];
5337
5488
  return override && opt ? { ...override, ...opt } : (opt ?? override);
5338
5489
  }
5339
- function getKeys(source, override, enumerable = true) {
5490
+ function getKeysImpl(source, override, enumerable, symbols) {
5340
5491
  // Plain objects can't trigger proxy traps — only pay for the untrack
5341
5492
  // closure when the source is itself a wrapped store (store-in-store).
5342
5493
  const baseKeys = source[$TARGET]
5343
- ? untrack(() => (enumerable ? Object.keys(source) : Reflect.ownKeys(source)))
5494
+ ? untrack(() =>
5495
+ enumerable
5496
+ ? symbols
5497
+ ? ownEnumerableKeys(source)
5498
+ : Object.keys(source)
5499
+ : Reflect.ownKeys(source)
5500
+ )
5344
5501
  : enumerable
5345
- ? Object.keys(source)
5502
+ ? symbols
5503
+ ? ownEnumerableKeysPlain(source)
5504
+ : Object.keys(source)
5346
5505
  : Reflect.ownKeys(source);
5347
- if (!override) return baseKeys;
5506
+ return override ? mergeOverrideKeys(baseKeys, override) : baseKeys;
5507
+ }
5508
+ function getKeys(source, override, enumerable = true) {
5509
+ return getKeysImpl(source, override, enumerable, false);
5510
+ }
5511
+ function getStoreKeys(source, override) {
5512
+ return getKeysImpl(source, override, true, true);
5513
+ }
5514
+ function getStoreSymbols(source, override) {
5515
+ const symbols = source[$TARGET]
5516
+ ? untrack(() => ownEnumerableSymbols(source))
5517
+ : ownEnumerableSymbols(source);
5518
+ return override ? mergeOverrideKeys(symbols, override, true) : symbols;
5519
+ }
5520
+ // Shared override-layer merge for key enumeration: adds live override keys,
5521
+ // drops $DELETED ones. `symbolsOnly` scopes the override scan for the
5522
+ // array-metadata passes.
5523
+ function mergeOverrideKeys(baseKeys, override, symbolsOnly) {
5348
5524
  const keys = new Set(baseKeys);
5349
- const overrides = Reflect.ownKeys(override);
5525
+ const overrides = symbolsOnly
5526
+ ? Object.getOwnPropertySymbols(override)
5527
+ : Reflect.ownKeys(override);
5350
5528
  for (const key of overrides) {
5351
5529
  if (override[key] !== $DELETED) keys.add(key);
5352
5530
  else keys.delete(key);
@@ -5412,6 +5590,19 @@ function armOptimisticStoreWrite(target, store) {
5412
5590
  GlobalQueue._trackOptimisticStore(store);
5413
5591
  }
5414
5592
  }
5593
+ /**
5594
+ * Records which transition owns an optimistic layer entry (#2899), so a
5595
+ * settling action only consumes its own keys — the layer is store-wide, but
5596
+ * concurrent actions writing disjoint keys must revert independently, exactly
5597
+ * like optimistic signal nodes do via the transition's _optimisticNodes.
5598
+ * `activeTransition` is the write's transaction (action() opens it before the
5599
+ * body runs); null marks an ambient write that clears at plain flush end.
5600
+ * Same-key writes across actions keep last-write-wins layer semantics.
5601
+ */
5602
+ function stampOptimisticOwner(target, overrideKey, property) {
5603
+ if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
5604
+ (target[STORE_OPTIMISTIC_OWNERS] ??= Object.create(null))[property] = activeTransition;
5605
+ }
5415
5606
  function upsertStoreNode(target, nodes, property, prev, snapshotProps) {
5416
5607
  if (nodes[property]) return nodes[property];
5417
5608
  const initial = isWrappable(prev) ? wrap(prev, target) : prev;
@@ -5456,12 +5647,26 @@ function notifyStoreProperty(target, property, mode, value, prev, prevHas) {
5456
5647
  notifySelf(target);
5457
5648
  }
5458
5649
  let Writing = null;
5650
+ /**
5651
+ * A derived store's seed is a draft for the derive function, never an
5652
+ * observable value (#2897): until the firewall first resolves there is
5653
+ * nothing to read, so every consumer path throws NotReady — tracked reads
5654
+ * through their node (core read()), and the untracked fall-throughs in the
5655
+ * traps through this guard. Returning the seed leaked it; returning
5656
+ * `undefined` would break non-nullable types. Callers exempt the firewall
5657
+ * itself (the derive function works its own draft while uninitialized).
5658
+ */
5659
+ function throwIfUninitialized(target) {
5660
+ const firewall = target[STORE_FIREWALL];
5661
+ if (firewall && firewall._statusFlags & STATUS_UNINITIALIZED)
5662
+ throw firewall._error ?? new NotReadyError(firewall);
5663
+ }
5459
5664
  const storeTraps = {
5460
5665
  get(target, property, receiver) {
5461
5666
  if (property === $TARGET) return target;
5462
5667
  if (property === $PROXY) return receiver;
5463
5668
  if (property === $REFRESH) return target[STORE_FIREWALL];
5464
- if (pendingCheckActive) witnessAffectsMark(target);
5669
+ if (pendingCheckActive) witnessAffectsMark(target, property);
5465
5670
  if (property === $TRACK) {
5466
5671
  trackSelf(target);
5467
5672
  return receiver;
@@ -5542,24 +5747,26 @@ const storeTraps = {
5542
5747
  }
5543
5748
  }
5544
5749
  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" }
5750
+ // Safeguard parity with core read() (#2897): untracked store reads skip
5751
+ // node creation (and with it read()'s PENDING_ASYNC_UNTRACKED_READ
5752
+ // check), so a derived store's in-flight firewall must be consulted
5753
+ // here — otherwise a component-body read of a refetching store silently
5754
+ // returns a value the reader can never observe updating.
5755
+ if ((target[STORE_FIREWALL]?._statusFlags ?? 0) & STATUS_PENDING)
5756
+ throwPendingUntrackedRead(strictRead, { nodeName: property });
5757
+ warnStrictReadUntracked(strictRead, {
5758
+ nodeName: property,
5759
+ data: { strictRead, property, source: "store" }
5555
5760
  });
5556
- console.warn(message);
5557
5761
  }
5762
+ // Untracked fall-through (tracked reads already threw via their node in
5763
+ // read(); the dev strictRead error above wins first for memo parity).
5764
+ if (!selfRead) throwIfUninitialized(target);
5558
5765
  return isWrappable(value) ? wrap(value, target) : value;
5559
5766
  },
5560
5767
  has(target, property) {
5561
5768
  if (property === $PROXY || property === $TRACK || property === "__proto__") return true;
5562
- if (pendingCheckActive) witnessAffectsMark(target);
5769
+ if (pendingCheckActive) witnessAffectsMark(target, property);
5563
5770
  const hasLayer = getOverlayLayer(target, property);
5564
5771
  const has = hasLayer ? hasLayer[property] !== $DELETED : property in target[STORE_VALUE];
5565
5772
  if (writeOnly(target[$PROXY]) || getObserver() === target[STORE_FIREWALL]) return has;
@@ -5574,6 +5781,7 @@ const storeTraps = {
5574
5781
  if (getObserver()) {
5575
5782
  return read(getNode(target, nodes, property, has));
5576
5783
  }
5784
+ throwIfUninitialized(target);
5577
5785
  return has;
5578
5786
  },
5579
5787
  set(target, property, rawValue) {
@@ -5603,12 +5811,18 @@ const storeTraps = {
5603
5811
  const nextLength = isArrayIndexWrite && nextIndex > len ? nextIndex : undefined;
5604
5812
  if (prev === value && nextLength === undefined) return true;
5605
5813
  armOptimisticStoreWrite(target, store);
5606
- if (value !== undefined && value === base && nextLength === undefined)
5814
+ if (value !== undefined && value === base && nextLength === undefined) {
5607
5815
  delete target[overrideKey]?.[property];
5608
- else {
5816
+ if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
5817
+ delete target[STORE_OPTIMISTIC_OWNERS]?.[property];
5818
+ } else {
5609
5819
  const override = target[overrideKey] || (target[overrideKey] = Object.create(null));
5610
5820
  override[property] = value;
5611
- if (nextLength !== undefined) override.length = nextLength;
5821
+ stampOptimisticOwner(target, overrideKey, property);
5822
+ if (nextLength !== undefined) {
5823
+ override.length = nextLength;
5824
+ stampOptimisticOwner(target, overrideKey, "length");
5825
+ }
5612
5826
  }
5613
5827
  notifyStoreProperty(target, property, "set", value, prev, prevHas);
5614
5828
  // Shrinking an array's length must remove the truncated indices, otherwise
@@ -5627,6 +5841,7 @@ const storeTraps = {
5627
5841
  const prevIndex = i in override ? override[i] : state[i];
5628
5842
  if (!(i in override) && !(i in state)) continue;
5629
5843
  override[i] = $DELETED;
5844
+ stampOptimisticOwner(target, overrideKey, i);
5630
5845
  notifyStoreProperty(target, i, "delete", undefined, prevIndex, true);
5631
5846
  }
5632
5847
  }
@@ -5670,6 +5885,7 @@ const storeTraps = {
5670
5885
  property,
5671
5886
  normalizedDescriptor
5672
5887
  );
5888
+ stampOptimisticOwner(target, overrideKey, property);
5673
5889
  notifyStoreProperty(target, property, "invalidate");
5674
5890
  if (true) {
5675
5891
  const next =
@@ -5699,9 +5915,12 @@ const storeTraps = {
5699
5915
  ) {
5700
5916
  armOptimisticStoreWrite(target, target[$PROXY]);
5701
5917
  (target[overrideKey] || (target[overrideKey] = Object.create(null)))[property] = $DELETED;
5918
+ stampOptimisticOwner(target, overrideKey, property);
5702
5919
  } else if (target[overrideKey] && property in target[overrideKey]) {
5703
5920
  armOptimisticStoreWrite(target, target[$PROXY]);
5704
5921
  delete target[overrideKey][property];
5922
+ if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
5923
+ delete target[STORE_OPTIMISTIC_OWNERS]?.[property];
5705
5924
  } else return true;
5706
5925
  notifyStoreProperty(target, property, "delete", undefined, prev, true);
5707
5926
  });
@@ -5710,7 +5929,15 @@ const storeTraps = {
5710
5929
  },
5711
5930
  ownKeys(target) {
5712
5931
  if (pendingCheckActive) witnessAffectsMark(target);
5713
- if (getObserver() !== target[STORE_FIREWALL]) trackSelf(target);
5932
+ if (getObserver() !== target[STORE_FIREWALL]) {
5933
+ trackSelf(target);
5934
+ // trackSelf no-ops untracked, so enumeration of an unresolved derived
5935
+ // store would otherwise leak the seed's structure (#2897). The write
5936
+ // path is exempt (like the get/has traps' writeOnly early returns):
5937
+ // the first landing's reconcile enumerates the store while
5938
+ // STATUS_UNINITIALIZED is still set — it IS the initialization.
5939
+ if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUninitialized(target);
5940
+ }
5714
5941
  // Merge optimistic override with regular override for key enumeration
5715
5942
  let keys = getKeys(target[STORE_VALUE], target[STORE_OVERRIDE], false);
5716
5943
  if (target[STORE_OPTIMISTIC_OVERRIDE]) {
@@ -5852,7 +6079,7 @@ function propagateAffectsMark(node) {
5852
6079
  const sentinel = getAffectsSentinel(node);
5853
6080
  const error = new NotReadyError(sentinel);
5854
6081
  forEachDependent(node, sub => {
5855
- if (sub._pendingSource !== sentinel && !sub._pendingSources?.has(sentinel)) {
6082
+ if (!sub._pendingSources?.has(sentinel)) {
5856
6083
  notifyStatus(sub, STATUS_PENDING, error);
5857
6084
  }
5858
6085
  });
@@ -5895,7 +6122,7 @@ function markAffects(node) {
5895
6122
  /**
5896
6123
  * Registers one `affects()` mark on a node: counts it, records the
5897
6124
  * registration with the current transaction (after initTransition the queue's
5898
- * array aliases the active transition's, mirroring `_optimisticNodes`), and
6125
+ * batch IS the active transition, mirroring `_optimisticNodes`), and
5899
6126
  * propagates STATUS_PENDING downstream on the status rails so everything
5900
6127
  * DERIVED from the marked data reads pending too. Propagation runs on every
5901
6128
  * registration (not just the first): subscribers gained since an earlier
@@ -5903,7 +6130,7 @@ function markAffects(node) {
5903
6130
  */
5904
6131
  function registerAffectsMark(node) {
5905
6132
  markAffects(node);
5906
- globalQueue._affectsNodes.push(node);
6133
+ globalQueue._batch._affectsNodes.push(node);
5907
6134
  propagateAffectsMark(node);
5908
6135
  schedule();
5909
6136
  }
@@ -5946,7 +6173,7 @@ function onlyMarkPending(el) {
5946
6173
  for (const s of sources) if (!s._affectsFor) return false;
5947
6174
  return true;
5948
6175
  }
5949
- return !!el._pendingSource?._affectsFor;
6176
+ return false;
5950
6177
  }
5951
6178
  /**
5952
6179
  * Collect the still-live marked nodes behind a pended owner's sentinel
@@ -5960,11 +6187,7 @@ function onlyMarkPending(el) {
5960
6187
  * `GlobalQueue._collectMarkSources`, gated on `activeAffectsMarks`.
5961
6188
  */
5962
6189
  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) {
6190
+ if (el._pendingSources) {
5968
6191
  for (const s of el._pendingSources) {
5969
6192
  const marked = s._affectsFor;
5970
6193
  if (marked && marked._affectsCount) into.push(marked);
@@ -6051,54 +6274,90 @@ function createOptimisticStore(first, second, options) {
6051
6274
  }
6052
6275
  // Clear the optimistic overrides of a settling batch of stores and notify
6053
6276
  // 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) {
6277
+ // scheduler's flush tail carries only a size-guarded hook call. The
6278
+ // completing transition scopes each clear to its own layer keys (#2899).
6279
+ function clearOptimisticStores(stores, completing) {
6056
6280
  for (const store of stores) {
6057
6281
  const target = store[$TARGET];
6058
- if (target?.[STORE_OPTIMISTIC_OVERRIDE]) clearOptimisticOverride(target);
6282
+ if (target?.[STORE_OPTIMISTIC_OVERRIDE]) clearOptimisticOverride(target, completing);
6059
6283
  }
6060
6284
  stores.clear();
6061
6285
  schedule();
6062
6286
  }
6063
- function clearOptimisticOverride(target) {
6287
+ /**
6288
+ * Consume optimistic layer entries and reset their backing nodes to base.
6289
+ * With `completing` (settle path, #2899) only entries the settling
6290
+ * transaction owns are consumed — the layer is store-wide but concurrent
6291
+ * actions revert independently, so keys stamped by a still-in-flight
6292
+ * transition survive (node-level overrides already have this granularity via
6293
+ * _optimisticNodes; this is the layer's half). `null` consumes ambient
6294
+ * (transaction-less) entries at plain flush end. Omitted (projection landing:
6295
+ * fresh authoritative data) consumes everything — the correction supersedes
6296
+ * every tentative layer.
6297
+ */
6298
+ function clearOptimisticOverride(target, completing) {
6064
6299
  const override = target[STORE_OPTIMISTIC_OVERRIDE];
6065
6300
  if (!override) return;
6066
6301
  const nodes = target[STORE_NODE];
6067
- delete target[STORE_OPTIMISTIC_OVERRIDE];
6068
- // Notify signals for all overridden properties
6302
+ const owners = target[STORE_OPTIMISTIC_OWNERS];
6303
+ const scoped = completing !== undefined;
6304
+ let cleared = false;
6305
+ let remaining = false;
6069
6306
  // Use projectionWriteActive to bypass optimistic signal behavior (no lane creation)
6070
6307
  // This ensures reversion effects go to regular queues, not lane queues
6071
6308
  const wasProjectionWriteActive = projectionWriteActive;
6072
6309
  setProjectionWriteActive(true);
6073
6310
  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();
6311
+ for (const key of Reflect.ownKeys(override)) {
6312
+ if (scoped) {
6313
+ let owner = owners?.[key] ?? null;
6314
+ // Resolve merge chains (entangled actions settle as one); path-compress
6315
+ // so later keys skip the walk. A dead owner (`_done === true`) settled
6316
+ // through some other path — never strand its entry. A null owner is an
6317
+ // ambient write: its batch belongs to whichever transaction adopted it
6318
+ // (initTransition mid-batch) or to the plain flush, so it clears on
6319
+ // whichever clear call reaches this store first.
6320
+ if (owner) {
6321
+ if (typeof owner._done === "object") owner = owners[key] = currentTransition(owner);
6322
+ if (owner !== completing && owner._done !== true) {
6323
+ remaining = true;
6324
+ continue;
6093
6325
  }
6094
6326
  }
6095
6327
  }
6096
- // Notify $TRACK
6097
- if (nodes[$TRACK]) {
6098
- nodes[$TRACK]._optimisticLane = undefined;
6099
- notifySelf(target);
6328
+ delete override[key];
6329
+ if (owners) delete owners[key];
6330
+ cleared = true;
6331
+ const node = nodes?.[key];
6332
+ if (node) {
6333
+ // Clear lane association so effects go to regular queue
6334
+ node._optimisticLane = undefined;
6335
+ // Re-read from base — this key left the optimistic layer above, so the
6336
+ // overlay resolves to STORE_OVERRIDE or STORE_VALUE.
6337
+ const layer = getOverlayLayer(target, key);
6338
+ const baseValue = layer ? layer[key] : target[STORE_VALUE][key];
6339
+ const value = baseValue === $DELETED ? undefined : baseValue;
6340
+ const next = isWrappable(value) ? wrap(value, target) : value;
6341
+ const prev = visibleNodeValue(node);
6342
+ node._overrideValue = NOT_PENDING;
6343
+ node._overrideOwner = null;
6344
+ node._pendingValue = NOT_PENDING;
6345
+ node._value = next;
6346
+ if (!node._equals || !node._equals(prev, next)) {
6347
+ insertSubs(node, true);
6348
+ schedule();
6349
+ }
6100
6350
  }
6101
6351
  }
6352
+ if (!remaining) {
6353
+ delete target[STORE_OPTIMISTIC_OVERRIDE];
6354
+ delete target[STORE_OPTIMISTIC_OWNERS];
6355
+ }
6356
+ // Notify $TRACK
6357
+ if (cleared && nodes?.[$TRACK]) {
6358
+ nodes[$TRACK]._optimisticLane = undefined;
6359
+ notifySelf(target);
6360
+ }
6102
6361
  } finally {
6103
6362
  setProjectionWriteActive(wasProjectionWriteActive);
6104
6363
  }
@@ -6309,6 +6568,21 @@ function snapshotImpl(item, track, map, lookup) {
6309
6568
  result[i] = unwrapped;
6310
6569
  }
6311
6570
  }
6571
+ // Enumerate array symbols separately to avoid scanning indices twice.
6572
+ // Spread copies omit symbols, so assign them after the numeric walk.
6573
+ const symbols = lookup ? getStoreSymbols(item, override) : [];
6574
+ for (let i = 0, l = symbols.length; i < l; i++) {
6575
+ const prop = symbols[i];
6576
+ const desc = getPropertyDescriptor(item, override, prop);
6577
+ if (!desc || desc.get) continue;
6578
+ v = override && prop in override ? override[prop] : item[prop];
6579
+ if (track && isWrappable(v)) wrap(v, target);
6580
+ unwrapped = snapshotImpl(v, track, map, lookup);
6581
+ if (unwrapped !== v || result) {
6582
+ if (!result) map.set(item, (result = Object.assign([...item], item)));
6583
+ result[prop] = unwrapped;
6584
+ }
6585
+ }
6312
6586
  // Deleted trailing slots are skipped above, so restore length to preserve
6313
6587
  // holes instead of truncating the copy (#2846) — mirrors unwrapStoreValue.
6314
6588
  if (result) result.length = len;
@@ -6316,7 +6590,9 @@ function snapshotImpl(item, track, map, lookup) {
6316
6590
  // Specialized walk for the common no-overlay case (from #2756): the own
6317
6591
  // descriptor gives the value directly, so each property is read once with
6318
6592
  // no overlay membership checks.
6319
- const keys = getKeys(item, undefined);
6593
+ // A lookup means this object belongs to an immutable store backing tree,
6594
+ // even if that nested value has not needed its own proxy yet.
6595
+ const keys = lookup ? getStoreKeys(item, undefined) : getKeys(item, undefined);
6320
6596
  for (let i = 0, l = keys.length; i < l; i++) {
6321
6597
  const prop = keys[i];
6322
6598
  const desc = Object.getOwnPropertyDescriptor(item, prop);
@@ -6332,7 +6608,9 @@ function snapshotImpl(item, track, map, lookup) {
6332
6608
  }
6333
6609
  }
6334
6610
  } else {
6335
- const keys = getKeys(item, override);
6611
+ // An override only exists on a store record, and the target branch above
6612
+ // always set `lookup` alongside it — so this branch is always store-keyed.
6613
+ const keys = getStoreKeys(item, override);
6336
6614
  for (let i = 0, l = keys.length; i < l; i++) {
6337
6615
  let prop = keys[i];
6338
6616
  const desc = getPropertyDescriptor(item, override, prop);
@@ -6599,6 +6877,15 @@ function mapArray(list, map, options) {
6599
6877
  return accessor(node);
6600
6878
  }
6601
6879
  const pureOptions = { ownedWrite: true };
6880
+ // Exception safety (#2903): a map callback can throw NotReadyError mid-pass
6881
+ // (async read), and the computed re-runs the whole pass after settle. Every
6882
+ // pass therefore STAGES its work — new rows are created into temp arrays and
6883
+ // removals are deferred — and commits to `this` only after every mapper
6884
+ // succeeded. An aborted pass disposes just the owners it created and leaves
6885
+ // `_items`/`_mappings`/`_nodes`/`_rows`/`_indexes`/`_len` exactly as they
6886
+ // were, so the retry diffs against uncorrupted state. Consequence of the
6887
+ // strong-abort ordering: removed rows now dispose AFTER the pass's new rows
6888
+ // are created (you cannot destroy state before knowing the pass will land).
6602
6889
  function updateKeyedMap() {
6603
6890
  const newItems = this._list() || [],
6604
6891
  newLen = newItems.length;
@@ -6606,25 +6893,26 @@ function updateKeyedMap() {
6606
6893
  runWithOwner(this._owner, () => {
6607
6894
  let i,
6608
6895
  j,
6896
+ rows,
6897
+ indexes,
6898
+ // Mappers write freshly-created row/index signals into the STAGE
6899
+ // arrays (`rows`/`indexes`), never into `this._rows`/`this._indexes`.
6609
6900
  mapper = this._rows
6610
6901
  ? this._byIndex
6611
6902
  ? () => {
6612
- this._rows[j] = signal(newItems[j], pureOptions);
6613
- return this._map(accessor(this._rows[j]), j);
6903
+ rows[j] = signal(newItems[j], pureOptions);
6904
+ return this._map(accessor(rows[j]), j);
6614
6905
  }
6615
6906
  : () => {
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
- );
6907
+ rows[j] = signal(newItems[j], pureOptions);
6908
+ indexes && (indexes[j] = signal(j, pureOptions));
6909
+ return this._map(accessor(rows[j]), indexes ? accessor(indexes[j]) : undefined);
6622
6910
  }
6623
6911
  : this._indexes
6624
6912
  ? () => {
6625
6913
  const item = newItems[j];
6626
- this._indexes[j] = signal(j, pureOptions);
6627
- return this._map(item, accessor(this._indexes[j]));
6914
+ indexes[j] = signal(j, pureOptions);
6915
+ return this._map(item, accessor(indexes[j]));
6628
6916
  }
6629
6917
  : () => {
6630
6918
  const item = newItems[j];
@@ -6642,19 +6930,31 @@ function updateKeyedMap() {
6642
6930
  this._indexes && (this._indexes = []);
6643
6931
  }
6644
6932
  if (this._fallback && !this._mappings[0]) {
6645
- // create fallback
6933
+ // an aborted fallback attempt leaves an owner without a mapping;
6934
+ // dispose it before re-creating
6935
+ this._nodes[0]?.dispose();
6646
6936
  this._mappings[0] = runWithOwner((this._nodes[0] = createOwner()), this._fallback);
6647
6937
  }
6648
6938
  }
6649
6939
  // fast path for new create
6650
6940
  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);
6941
+ const mappings = new Array(newLen);
6942
+ const nodes = new Array(newLen);
6943
+ rows = this._rows && new Array(newLen);
6944
+ indexes = this._indexes && new Array(newLen);
6945
+ try {
6946
+ for (j = 0; j < newLen; j++) mappings[j] = runWithOwner((nodes[j] = createOwner()), mapper);
6947
+ } catch (err) {
6948
+ for (i = 0; i <= j; i++) nodes[i]?.dispose();
6949
+ throw err;
6657
6950
  }
6951
+ // commit
6952
+ if (this._nodes[0]) this._nodes[0].dispose(); // previous fallback
6953
+ this._mappings = mappings;
6954
+ this._nodes = nodes;
6955
+ rows && (this._rows = rows);
6956
+ indexes && (this._indexes = indexes);
6957
+ this._items = newItems.slice(0);
6658
6958
  this._len = newLen;
6659
6959
  } else {
6660
6960
  let start,
@@ -6664,10 +6964,12 @@ function updateKeyedMap() {
6664
6964
  key,
6665
6965
  newIndices,
6666
6966
  newIndicesNext,
6967
+ removed,
6968
+ created,
6667
6969
  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;
6970
+ tempNodes = new Array(newLen);
6971
+ rows = this._rows ? new Array(newLen) : undefined;
6972
+ indexes = this._indexes ? new Array(newLen) : undefined;
6671
6973
  // skip common prefix
6672
6974
  for (
6673
6975
  start = 0, end = Math.min(this._len, newLen);
@@ -6689,8 +6991,8 @@ function updateKeyedMap() {
6689
6991
  ) {
6690
6992
  temp[newEnd] = this._mappings[end];
6691
6993
  tempNodes[newEnd] = this._nodes[end];
6692
- tempRows && (tempRows[newEnd] = this._rows[end]);
6693
- tempIndexes && (tempIndexes[newEnd] = this._indexes[end]);
6994
+ rows && (rows[newEnd] = this._rows[end]);
6995
+ indexes && (indexes[newEnd] = this._indexes[end]);
6694
6996
  }
6695
6997
  // 0) prepare a map of all indices in newItems, scanning backwards so we encounter them in natural order
6696
6998
  newIndices = new Map();
@@ -6702,7 +7004,7 @@ function updateKeyedMap() {
6702
7004
  newIndicesNext[j] = i === undefined ? -1 : i;
6703
7005
  newIndices.set(key, j);
6704
7006
  }
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
7007
+ // 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
7008
  for (i = start; i <= end; i++) {
6707
7009
  item = this._items[i];
6708
7010
  key = this._key ? this._key(item) : item;
@@ -6710,32 +7012,40 @@ function updateKeyedMap() {
6710
7012
  if (j !== undefined && j !== -1) {
6711
7013
  temp[j] = this._mappings[i];
6712
7014
  tempNodes[j] = this._nodes[i];
6713
- tempRows && (tempRows[j] = this._rows[i]);
6714
- tempIndexes && (tempIndexes[j] = this._indexes[i]);
7015
+ rows && (rows[j] = this._rows[i]);
7016
+ indexes && (indexes[j] = this._indexes[i]);
6715
7017
  j = newIndicesNext[j];
6716
7018
  newIndices.set(key, j);
6717
- } else this._nodes[i].dispose();
7019
+ } else (removed ??= []).push(this._nodes[i]);
7020
+ }
7021
+ // 2) create new rows into the temp arrays; an abort disposes only these
7022
+ try {
7023
+ for (j = start; j < newLen; j++) {
7024
+ if (j in temp) continue;
7025
+ (created ??= []).push((tempNodes[j] = createOwner()));
7026
+ temp[j] = runWithOwner(tempNodes[j], mapper);
7027
+ }
7028
+ } catch (err) {
7029
+ if (created) for (i = 0; i < created.length; i++) created[i].dispose();
7030
+ throw err;
6718
7031
  }
6719
- // 2) set all the new values, pulling from the temp array if copied, otherwise entering the new value
7032
+ // 3) commit: land positions, then dispose exited rows
6720
7033
  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);
7034
+ this._mappings[j] = temp[j];
7035
+ this._nodes[j] = tempNodes[j];
7036
+ if (rows) {
7037
+ this._rows[j] = rows[j];
7038
+ setSignal(this._rows[j], newItems[j]);
7039
+ }
7040
+ if (indexes) {
7041
+ this._indexes[j] = indexes[j];
7042
+ setSignal(this._indexes[j], j);
6734
7043
  }
6735
7044
  }
6736
- // 3) in case the new set is shorter than the old, set the length of the mapped array
7045
+ if (removed) for (i = 0; i < removed.length; i++) removed[i].dispose();
7046
+ // 4) in case the new set is shorter than the old, set the length of the mapped array
6737
7047
  this._mappings = this._mappings.slice(0, (this._len = newLen));
6738
- // 4) save a copy of the mapped items for the next update
7048
+ // 5) save a copy of the mapped items for the next update
6739
7049
  this._items = newItems.slice(0);
6740
7050
  }
6741
7051
  });
@@ -6767,22 +7077,32 @@ function repeat(count, map, options) {
6767
7077
  }
6768
7078
  }
6769
7079
  : 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
- );
7080
+ const data = {
7081
+ _owner: createOwner(),
7082
+ _len: 0,
7083
+ _offset: 0,
7084
+ _count: count,
7085
+ _map: wrappedMap,
7086
+ _nodes: [],
7087
+ _mappings: [],
7088
+ _from: options?.from,
7089
+ _fallback: options?.fallback
7090
+ };
7091
+ const node = computed(updateRepeat.bind(data));
7092
+ // Same as mapArray: untracked reads inside the internal owner resolve via
7093
+ // _parentComputed, so async reads in row callbacks register with the node
7094
+ // (pending tracking + post-settle retry) instead of vanishing.
7095
+ data._owner._parentComputed = node;
6783
7096
  node._config &= ~CONFIG_AUTO_DISPOSE;
6784
7097
  return accessor(node);
6785
7098
  }
7099
+ // Same staged-commit discipline as `updateKeyedMap` (#2903): the retained
7100
+ // window overlap is copied into fresh arrays, missing indexes are created
7101
+ // into them, and `this` is only touched — including disposal of rows leaving
7102
+ // the window — after every `_map` call succeeded. A NotReadyError mid-pass
7103
+ // disposes only the owners this pass created and leaves prior state intact
7104
+ // for the post-settle retry. The overlap math also subsumes the previous
7105
+ // disjoint-window/front-clear/end-clear/shift special cases.
6786
7106
  function updateRepeat() {
6787
7107
  const newLen = this._count();
6788
7108
  const from = this._from?.() || 0;
@@ -6793,67 +7113,46 @@ function updateRepeat() {
6793
7113
  this._nodes = [];
6794
7114
  this._mappings = [];
6795
7115
  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).
7116
+ // Reset offset to match the cleared data (#2767, repro 2).
6800
7117
  this._offset = 0;
6801
7118
  }
6802
7119
  if (this._fallback && !this._mappings[0]) {
6803
- // create fallback
7120
+ // an aborted fallback attempt leaves an owner without a mapping;
7121
+ // dispose it before re-creating
7122
+ this._nodes[0]?.dispose();
6804
7123
  this._mappings[0] = runWithOwner((this._nodes[0] = createOwner()), this._fallback);
6805
7124
  }
6806
7125
  return;
6807
7126
  }
6808
7127
  const to = from + newLen;
6809
7128
  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;
7129
+ // Retained overlap [keepStart, keepEnd) in global indexes; empty when the
7130
+ // windows are disjoint or when coming from empty/fallback.
7131
+ const keepStart = Math.max(from, this._offset);
7132
+ const keepEnd = Math.min(to, prevTo);
7133
+ const mappings = new Array(newLen);
7134
+ const nodes = new Array(newLen);
7135
+ for (let i = keepStart; i < keepEnd; i++) {
7136
+ nodes[i - from] = this._nodes[i - this._offset];
7137
+ mappings[i - from] = this._mappings[i - this._offset];
6824
7138
  }
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
- );
7139
+ try {
7140
+ for (let i = from; i < to; i++) {
7141
+ if (i >= keepStart && i < keepEnd) continue;
7142
+ mappings[i - from] = runWithOwner((nodes[i - from] = createOwner()), () => this._map(i));
6849
7143
  }
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);
7144
+ } catch (err) {
7145
+ for (let i = from; i < to; i++)
7146
+ if ((i < keepStart || i >= keepEnd) && nodes[i - from]) nodes[i - from].dispose();
7147
+ throw err;
7148
+ }
7149
+ // commit: dispose the previous fallback or the rows leaving the window
7150
+ if (this._len === 0) this._nodes[0]?.dispose();
7151
+ else
7152
+ for (let i = this._offset; i < prevTo; i++)
7153
+ if (i < from || i >= to) this._nodes[i - this._offset].dispose();
7154
+ this._mappings = mappings;
7155
+ this._nodes = nodes;
6857
7156
  this._offset = from;
6858
7157
  this._len = newLen;
6859
7158
  });