@solidjs/signals 2.0.0-beta.25 → 2.0.0-beta.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dev.js CHANGED
@@ -591,10 +591,14 @@ function resetErrorHalt() {
591
591
  halted = false;
592
592
  haltNotified = false;
593
593
  }
594
+ // Identifies one child-traversal pass in `Queue.run` so a rescan after the
595
+ // child list shifts can tell "already run this pass" from "still pending".
596
+ let queueRunToken = 0;
594
597
  class Queue {
595
598
  _parent = null;
596
599
  _queues = [[], []];
597
600
  _children = [];
601
+ _ranAt = 0;
598
602
  created = clock;
599
603
  addChild(child) {
600
604
  this._children.push(child);
@@ -617,7 +621,27 @@ class Queue {
617
621
  this._queues[type - 1] = [];
618
622
  runQueue$1(effects, type);
619
623
  }
620
- for (let i = 0; i < this._children.length; i++) this._children[i].run?.(type);
624
+ // Effects run here can dispose owners, and disposal removes queues from
625
+ // this list — the running child itself, an earlier sibling, or several at
626
+ // once. A plain index walk then skips whatever shifted into the cursor.
627
+ // Stamping each child before it runs makes the pass idempotent, so a shift
628
+ // can be recovered by rescanning from the front and every child still runs
629
+ // exactly once. Children appended mid-pass carry a stale stamp and run,
630
+ // matching the previous live-array behaviour.
631
+ const children = this._children;
632
+ const token = ++queueRunToken;
633
+ for (let i = 0; i < children.length; ) {
634
+ const child = children[i];
635
+ if (child._ranAt !== token) {
636
+ child._ranAt = token;
637
+ child.run?.(type);
638
+ if (children[i] !== child) {
639
+ i = 0;
640
+ continue;
641
+ }
642
+ }
643
+ i++;
644
+ }
621
645
  }
622
646
  enqueue(type, fn) {
623
647
  if (type) {
@@ -1091,11 +1115,10 @@ function transitionComplete(transition) {
1091
1115
  break;
1092
1116
  }
1093
1117
  }
1094
- // Override blockage lives with the engine. Absent hook = "no optimistic
1095
- // blockage", which is exact: only _optimisticWrite (engine) pushes to
1096
- // _optimisticNodes, so without the engine the loop was vacuous anyway.
1097
- if (done && transition._optimisticNodes.length && GlobalQueue._transitionBlocked(transition))
1098
- done = false;
1118
+ // Override blockage lives with the engine (absent hook = "no optimistic
1119
+ // blockage"); the hook's loops over _optimisticNodes/_optimisticStores are
1120
+ // no-ops when the transition holds neither, so no pre-check is needed.
1121
+ if (done && GlobalQueue._transitionBlocked?.(transition)) done = false;
1099
1122
  done && (transition._done = true);
1100
1123
  return done;
1101
1124
  }
@@ -1103,9 +1126,6 @@ function currentTransition(transition) {
1103
1126
  while (transition._done && typeof transition._done === "object") transition = transition._done;
1104
1127
  return transition;
1105
1128
  }
1106
- function setActiveTransition(transition) {
1107
- activeTransition = transition;
1108
- }
1109
1129
  function runInTransition(transition, fn) {
1110
1130
  const prevTransition = activeTransition;
1111
1131
  try {
@@ -1707,8 +1727,70 @@ function forEachDependent(el, fn) {
1707
1727
  // Queue a node to re-run on the next flush (used both when a pending source
1708
1728
  // settles and when an `isPending` observer must re-evaluate after a real error):
1709
1729
  // shared scheduling helper in heap.ts (tracked effects bypass the heap).
1730
+ // Settle-time counterpart of unlinkSubs' last-one-out check. A lazy node that
1731
+ // loses its last subscriber while STATUS_PENDING is exempt from autodispose
1732
+ // (the in-flight work is an observer), so whatever CLEARS that pending state
1733
+ // must run the release — otherwise the node stays linked and recomputes
1734
+ // forever with zero subscribers (#2934). The node's own promise/iterator
1735
+ // callbacks handle their own release (settleAutodispose in handleAsync); this
1736
+ // covers derivatively-pending dependents, which have no callbacks of their own.
1737
+ function releaseIfSettledUnobserved(node) {
1738
+ node._fn &&
1739
+ node._config & CONFIG_AUTO_DISPOSE &&
1740
+ !node._subs &&
1741
+ !(node._flags & REACTIVE_ZOMBIE) &&
1742
+ !(node._statusFlags & STATUS_PENDING) &&
1743
+ unobserved(node);
1744
+ }
1745
+ // Error-path sweep: notifyStatus(STATUS_ERROR) clears dependents' pending
1746
+ // sources through its own recursion (no per-node settle callback), so after
1747
+ // the propagation completes, walk the same graph for stranded lazy nodes.
1748
+ // Collect-then-release so unobserved() never unlinks under the walk.
1749
+ function releaseSettledDependents(el) {
1750
+ let candidates;
1751
+ const visited = new Set();
1752
+ const visit = node => {
1753
+ if (visited.has(node)) return;
1754
+ visited.add(node);
1755
+ if (!node._subs && node._config & CONFIG_AUTO_DISPOSE) (candidates ??= []).push(node);
1756
+ forEachDependent(node, visit);
1757
+ };
1758
+ forEachDependent(el, visit);
1759
+ if (candidates) for (const node of candidates) releaseIfSettledUnobserved(node);
1760
+ }
1761
+ // Error-dimension twin of settlePendingSource's blocked re-enqueue (#2949):
1762
+ // a node in STATUS_ERROR that recovers by recomputing to an UNCHANGED value
1763
+ // fires no value notification — the recovery is completely silent. But a
1764
+ // dependent that re-ran during the error window consumed its dirty flag and
1765
+ // committed nothing (the fresh sibling values it read were absorbed into an
1766
+ // errored run), so its committed value is stale. The propagated error is one
1767
+ // object identity down the whole dependent tree, and holding it is exactly
1768
+ // the "blocked on this error" marker — re-enqueue those holders so they
1769
+ // re-run: fresh values commit and flow, and a dependent with another
1770
+ // still-broken source simply re-errors. The async dimension needs no twin of
1771
+ // its own: recovery there passes through a pending window whose re-runners
1772
+ // set _blocked and ride settlePendingSource. Walks the full dependent graph
1773
+ // (releaseSettledDependents shape): identity holders can sit below an
1774
+ // intermediate whose own error state has since been scrubbed or replaced
1775
+ // (e.g. an error boundary's tree node).
1776
+ function settleErroredDependents(el, error) {
1777
+ let scheduled = false;
1778
+ const visited = new Set();
1779
+ const visit = node => {
1780
+ if (visited.has(node)) return;
1781
+ visited.add(node);
1782
+ if (node._error === error) {
1783
+ enqueueSub(node);
1784
+ scheduled = true;
1785
+ }
1786
+ forEachDependent(node, visit);
1787
+ };
1788
+ forEachDependent(el, visit);
1789
+ if (scheduled) schedule();
1790
+ }
1710
1791
  function settlePendingSource(el) {
1711
1792
  let scheduled = false;
1793
+ let released;
1712
1794
  const visited = new Set();
1713
1795
  // Companion updates no-op without the verdict layer (null hook).
1714
1796
  const updateCompanions = GlobalQueue._updatePendingSignal;
@@ -1729,10 +1811,17 @@ function settlePendingSource(el) {
1729
1811
  scheduled = true;
1730
1812
  }
1731
1813
  node._blocked = false;
1814
+ // Fully settled with nobody watching: release candidate (#2934). Checked
1815
+ // again at release time — deferred so unobserved() can't unlink subs
1816
+ // lists this walk is still iterating.
1817
+ if (!node._subs && node._config & CONFIG_AUTO_DISPOSE) (released ??= []).push(node);
1732
1818
  }
1733
1819
  forEachDependent(node, settle);
1734
1820
  };
1735
1821
  forEachDependent(el, settle);
1822
+ // Release before the flush schedule below: unobserved() pulls the node back
1823
+ // out of the heap, so the enqueueSub above never recomputes a released node.
1824
+ if (released) for (const node of released) releaseIfSettledUnobserved(node);
1736
1825
  if (scheduled) schedule();
1737
1826
  }
1738
1827
  // Object-thenable detection (Promises/A+ shape).
@@ -1776,12 +1865,41 @@ function handleAsync(el, result, setter) {
1776
1865
  }
1777
1866
  el._inFlight = result;
1778
1867
  let syncValue;
1868
+ // Settle-time transition re-entry. The loading rail is invisible to
1869
+ // transactions (#2933): a boundary-caught first load never registers as an
1870
+ // async reporter, so its settle — the boundary's fallback -> content
1871
+ // reveal — must flow ambiently. The node can still carry a `_transition`
1872
+ // stamp (pending-node bookkeeping rides through the stamping sites), and
1873
+ // blindly re-entering that stamped, still-incomplete transaction stashed
1874
+ // the reveal with it — a deadlock when the transaction's completion
1875
+ // depended on the reveal (#2937). An ESCAPED first load did register and
1876
+ // keeps transition scheduling; initialized (value-holding) pending settles
1877
+ // are the transaction's reveal machinery and always re-enter.
1878
+ const settleTransition = () => {
1879
+ const transition = resolveTransition(el);
1880
+ if (
1881
+ transition &&
1882
+ el._statusFlags & STATUS_UNINITIALIZED &&
1883
+ !currentTransition(transition)._asyncReporters.has(el)
1884
+ ) {
1885
+ // Drop the stale stamp too: the plain settle write (setSignal) and the
1886
+ // stash-path restamp both re-enter the transaction through it.
1887
+ el._transition = null;
1888
+ return;
1889
+ }
1890
+ globalQueue.initTransition(transition);
1891
+ };
1779
1892
  const handleError = error => {
1780
1893
  if (el._inFlight !== result) return;
1781
- globalQueue.initTransition(resolveTransition(el));
1894
+ settleTransition();
1782
1895
  // NotReadyError from rejected promises should be treated as pending, not error
1783
- notifyStatus(el, error instanceof NotReadyError ? STATUS_PENDING : STATUS_ERROR, error);
1896
+ const stillPending = error instanceof NotReadyError;
1897
+ notifyStatus(el, stillPending ? STATUS_PENDING : STATUS_ERROR, error);
1784
1898
  el._time = clock;
1899
+ // A real error settles derivatively-pending dependents (notifyStatus
1900
+ // cleared their pending sources), so stranded lazy ones release here —
1901
+ // the error twin of settlePendingSource's release (#2934).
1902
+ if (!stillPending) releaseSettledDependents(el);
1785
1903
  };
1786
1904
  const asyncWrite = (value, then) => {
1787
1905
  if (el._inFlight !== result) return;
@@ -1789,7 +1907,7 @@ function handleAsync(el, result, setter) {
1789
1907
  // skip this stale async result — the upcoming flush will recompute the node
1790
1908
  // with the new value, creating a fresh Promise that supersedes this one.
1791
1909
  if (el._flags & (REACTIVE_DIRTY | REACTIVE_OPTIMISTIC_DIRTY)) return;
1792
- globalQueue.initTransition(resolveTransition(el));
1910
+ settleTransition();
1793
1911
  const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
1794
1912
  trimStaleDeps(el);
1795
1913
  clearStatus(el);
@@ -1861,10 +1979,14 @@ function handleAsync(el, result, setter) {
1861
1979
  // work (a lazy async memo would otherwise tear down and re-execute — one
1862
1980
  // fetch per suspended re-read). Settling is that observer's release, so
1863
1981
  // it runs the same last-one-out check the other release sites run.
1982
+ // Returns whether the node released, so the iterator branch can stop
1983
+ // pulling values instead of pumping an unobserved stream forever (#2935).
1864
1984
  const settleAutodispose = () => {
1865
1985
  if (el._config & CONFIG_AUTO_DISPOSE && !el._subs && !(el._statusFlags & STATUS_PENDING)) {
1866
1986
  unobserved(el);
1987
+ return true;
1867
1988
  }
1989
+ return false;
1868
1990
  };
1869
1991
  if (thenable) {
1870
1992
  let resolved = false,
@@ -1916,6 +2038,12 @@ function handleAsync(el, result, setter) {
1916
2038
  if (isThenable(returned)) returned.then(undefined, () => {});
1917
2039
  } catch {}
1918
2040
  });
2041
+ // Release check before each next pull: an unobserved lazy node must tear
2042
+ // down (its cleanup above closes the iterator) instead of pumping the
2043
+ // stream forever with zero subscribers (#2935).
2044
+ const iterateOrRelease = () => {
2045
+ if (!settleAutodispose()) iterate();
2046
+ };
1919
2047
  const iterate = () => {
1920
2048
  let syncResult,
1921
2049
  syncError,
@@ -1932,7 +2060,7 @@ function handleAsync(el, result, setter) {
1932
2060
  return;
1933
2061
  } else if (!r.done) {
1934
2062
  hadValue = true;
1935
- asyncWrite(r.value, iterate);
2063
+ asyncWrite(r.value, iterateOrRelease);
1936
2064
  } else {
1937
2065
  completed = true;
1938
2066
  if (hadValue) {
@@ -1942,6 +2070,7 @@ function handleAsync(el, result, setter) {
1942
2070
  // Empty completion settles like the immediately-done sync path.
1943
2071
  asyncWrite(undefined);
1944
2072
  }
2073
+ settleAutodispose();
1945
2074
  }
1946
2075
  },
1947
2076
  e => {
@@ -1951,6 +2080,7 @@ function handleAsync(el, result, setter) {
1951
2080
  } else if (el._inFlight === result) {
1952
2081
  completed = true;
1953
2082
  handleError(e);
2083
+ settleAutodispose();
1954
2084
  }
1955
2085
  }
1956
2086
  );
@@ -2169,6 +2299,10 @@ function recompute(el, create = false) {
2169
2299
  let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY);
2170
2300
  const hasOverride = el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING;
2171
2301
  const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
2302
+ // Outgoing error, captured before the compute clears status: if this run
2303
+ // recovers to an unchanged value, dependents still holding this object must
2304
+ // be swept (settleErroredDependents, #2949).
2305
+ const outgoingError = el._statusFlags & STATUS_ERROR ? el._error : undefined;
2172
2306
  // Re-ask classification lives in the verdict module; capture the flag before
2173
2307
  // the recompute wipes _flags below.
2174
2308
  const hadReask = (el._flags & REACTIVE_REASK) !== 0;
@@ -2351,6 +2485,13 @@ function recompute(el, create = false) {
2351
2485
  insertIntoHeapHeight(s._sub, queueFor(s._sub));
2352
2486
  }
2353
2487
  }
2488
+ // Silent recovery: errored → unchanged value fires no notification, but
2489
+ // dependents still holding the propagated error consumed their dirty flag
2490
+ // in an errored run and may sit on stale commits (#2949). Changed-value
2491
+ // recoveries ride insertSubs above; a comparator throw re-errored the node
2492
+ // (el._error re-set), so this only runs on a genuinely clean recovery.
2493
+ if (outgoingError !== undefined && !valueChanged && !el._error)
2494
+ settleErroredDependents(el, outgoingError);
2354
2495
  }
2355
2496
  currentOptimisticLane = prevLane;
2356
2497
  const needsPendingCommit =
@@ -2759,15 +2900,19 @@ function read(el) {
2759
2900
  throw owner._error;
2760
2901
  }
2761
2902
  }
2762
- if (el._fn && el._statusFlags & STATUS_ERROR) {
2903
+ // `owner` is the computed itself, or the firewall behind a store node —
2904
+ // firewall-backed reads follow the same rules (memo parity, #2897 ruling):
2905
+ // an errored derive throws for every late reader instead of silently
2906
+ // serving node values (the seed, or last-good data after a failed refetch).
2907
+ if (owner._fn && owner._statusFlags & STATUS_ERROR) {
2763
2908
  // Only a genuine reactive re-read may retry an errored async source:
2764
2909
  // - tracking: owned/tracked scope only (never events / `untrack` / effect side-effect phase)
2765
2910
  // - !pendingCheckActive: an `isPending` probe observes the error, never refetches
2766
- // - el._time < clock: only on a later cycle than the one the error was found
2767
- if (tracking && !pendingCheckActive && el._time < clock) {
2768
- recompute(el);
2911
+ // - owner._time < clock: only on a later cycle than the one the error was found
2912
+ if (tracking && !pendingCheckActive && owner._time < clock) {
2913
+ recompute(owner);
2769
2914
  return read(el);
2770
- } else throw el._error;
2915
+ } else throw owner._error;
2771
2916
  }
2772
2917
  if (snapshotCaptureActive && c && c._config & CONFIG_IN_SNAPSHOT_SCOPE) {
2773
2918
  const sv = el._snapshotValue;
@@ -4064,7 +4209,12 @@ function action(genFn) {
4064
4209
  ctx = currentTransition(ctx);
4065
4210
  const i = ctx._actions.indexOf(it);
4066
4211
  if (i >= 0) ctx._actions.splice(i, 1);
4067
- setActiveTransition(ctx);
4212
+ // Re-adopt through initTransition like every other resumption site:
4213
+ // a bare setActiveTransition leaves globalQueue._batch as a detached
4214
+ // ambient batch, and anything registered before the scheduled flush
4215
+ // (held writes on a merging transition, optimistic overrides,
4216
+ // affects() marks) lands there with nothing to ever finalize it.
4217
+ globalQueue.initTransition(ctx);
4068
4218
  schedule();
4069
4219
  failed ? reject(e) : resolve(v);
4070
4220
  };
@@ -4655,6 +4805,21 @@ function itemKey(item, keyFn) {
4655
4805
  function keyedMatch(a, b, keyFn) {
4656
4806
  return a === b || (isWrappable(a) && isWrappable(b) && keyFn(a) === keyFn(b));
4657
4807
  }
4808
+ // A pair of array slots may only be merged into when both sides are real store
4809
+ // children of the SAME container kind. An array and an object are different
4810
+ // shapes, and merging one into the other leaves the slot's proxy permanently
4811
+ // mismatched with its value (an array target holding an object, so
4812
+ // `Array.isArray`/spread/`map` lie). The object diff has always applied this
4813
+ // rule; the array paths reach the same recursion through `keyedMatch` /
4814
+ // positional pairing, where two keyless wrappables "match" regardless of kind.
4815
+ function recursablePair(previous, next) {
4816
+ return (
4817
+ isWrappable(previous) &&
4818
+ isWrappable(next) &&
4819
+ !(rawValuesUsed && (isRawValue(previous) || isRawValue(next))) &&
4820
+ Array.isArray(previous) === Array.isArray(next)
4821
+ );
4822
+ }
4658
4823
  // Array reconciliation updates the slots it visits, then swaps STORE_VALUE.
4659
4824
  // Previously tracked keys that are absent from `next` still need invalidating,
4660
4825
  // and `in` dependencies should follow the new value's membership. Use
@@ -4719,11 +4884,7 @@ function applyStateChild(next, prevRaw, target, keyFn) {
4719
4884
  // Reconcile a single array slot: recurse into a wrappable pair, otherwise replace
4720
4885
  // the node's value outright (covers object→primitive and primitive→object).
4721
4886
  function applyArrayItem(next, previous, target, node, keyFn) {
4722
- if (
4723
- isWrappable(next) &&
4724
- isWrappable(previous) &&
4725
- !(rawValuesUsed && (isRawValue(previous) || isRawValue(next)))
4726
- ) {
4887
+ if (recursablePair(previous, next)) {
4727
4888
  const wrapped = wrap(previous, target);
4728
4889
  node && setSignal(node, wrapped);
4729
4890
  applyState(next, wrapped, keyFn);
@@ -4917,7 +5078,7 @@ function applyStateFast(next, target, keyFn) {
4917
5078
  // the recursion dispatch entirely. Raw-marked values are leaves:
4918
5079
  // replace the slot node instead of recursing.
4919
5080
  if (item !== next[start]) {
4920
- if (rawValuesUsed && (isRawValue(item) || isRawValue(next[start]))) {
5081
+ if (!recursablePair(item, next[start])) {
4921
5082
  arrayNodes?.[start] && setSignal(arrayNodes[start], wrapValue(next[start], target));
4922
5083
  } else applyStateChild(next[start], item, target, keyFn);
4923
5084
  }
@@ -4979,11 +5140,7 @@ function applyStateFast(next, target, keyFn) {
4979
5140
  } else if (next.length) {
4980
5141
  for (let i = 0, len = next.length; i < len; i++) {
4981
5142
  const item = previous[i];
4982
- if (
4983
- isWrappable(item) &&
4984
- isWrappable(next[i]) &&
4985
- !(rawValuesUsed && (isRawValue(item) || isRawValue(next[i])))
4986
- ) {
5143
+ if (recursablePair(item, next[i])) {
4987
5144
  if (item !== next[i]) applyStateChild(next[i], item, target, keyFn);
4988
5145
  } else {
4989
5146
  if (item !== next[i]) changed = true;
@@ -5093,8 +5250,8 @@ function applyStateSlow(next, target, keyFn) {
5093
5250
  );
5094
5251
  start++
5095
5252
  ) {
5096
- if (isWrappable(item) && isWrappable(next[start]) && item !== next[start]) {
5097
- if (rawValuesUsed && (isRawValue(item) || isRawValue(next[start]))) {
5253
+ if (item !== next[start] && isWrappable(item) && isWrappable(next[start])) {
5254
+ if (!recursablePair(item, next[start])) {
5098
5255
  nodes?.[start] && setSignal(nodes[start], wrapValue(next[start], target));
5099
5256
  } else applyState(next[start], wrap(item, target), keyFn);
5100
5257
  }
@@ -5156,11 +5313,7 @@ function applyStateSlow(next, target, keyFn) {
5156
5313
  } else if (next.length) {
5157
5314
  for (let i = 0, len = next.length; i < len; i++) {
5158
5315
  const item = getOverrideValue(previous, override, i, optOverride);
5159
- if (
5160
- isWrappable(item) &&
5161
- isWrappable(next[i]) &&
5162
- !(rawValuesUsed && (isRawValue(item) || isRawValue(next[i])))
5163
- ) {
5316
+ if (recursablePair(item, next[i])) {
5164
5317
  if (item !== next[i]) applyState(next[i], wrap(item, target), keyFn);
5165
5318
  } else {
5166
5319
  if (item !== next[i]) changed = true;
@@ -5215,6 +5368,58 @@ function applyStateSlow(next, target, keyFn) {
5215
5368
  // No-key reconcile: every item reports "no key", which routes array diffs to
5216
5369
  // the positional branch and object descent to plain per-property merging.
5217
5370
  const NOKEY = () => null;
5371
+ // Identity-as-key: distinct objects never match, so every slot takes the diff's
5372
+ // "not the same entity" branch and is replaced by reference rather than merged
5373
+ // into. Slots holding the same raw on both sides still keep their proxy.
5374
+ const IDENTITY = item => item;
5375
+ /**
5376
+ * Shared body of `reconcile()` and the projection commit. `replace` is the
5377
+ * only difference: a projection commit is a value swap, not a merge — its root
5378
+ * proxy is a cell handed out by `createProjection` that can never change
5379
+ * reference, so a derive returning a different entity is not the slot mistake
5380
+ * `reconcile()` throws on. Nothing below the root survives that swap, which is
5381
+ * the rule the keyed diff already applies at a nested slot on a key mismatch.
5382
+ *
5383
+ * @internal
5384
+ */
5385
+ function reconcileState(value, state, key, replace) {
5386
+ if (state == null) throw new Error("Cannot reconcile null or undefined state");
5387
+ // A projection commit whose derive returns a foreign store adopts it as the
5388
+ // live backing (store-in-store chain — the same shape as a store-proxy
5389
+ // seed): reads route through the inner store's own graph, so its updates
5390
+ // flow with no re-derive, and a derive with no dependencies never recomputes
5391
+ // (#2941). The diff below still runs against raw values so THIS store's
5392
+ // existing subscribers see the swap; the live proxy is installed after.
5393
+ // Shallow projections keep their raw-ingest contract and never chain.
5394
+ let chain;
5395
+ const target = replace ? state[$TARGET] : undefined;
5396
+ if (target !== undefined) {
5397
+ if (value?.[$TARGET] !== undefined && value[$TARGET] !== target && !target[STORE_SHALLOW]) {
5398
+ if (target[STORE_VALUE] === value) return; // already chained to this store
5399
+ chain = value;
5400
+ }
5401
+ // Re-diffing a previously chained backing goes through its raw — reads
5402
+ // off the outgoing proxy would subscribe this computed to a store it is
5403
+ // about to drop.
5404
+ while (target[STORE_VALUE]?.[$TARGET] !== undefined)
5405
+ target[STORE_VALUE] = unwrap(target[STORE_VALUE]);
5406
+ }
5407
+ if (key === null) applyState(value, state, NOKEY);
5408
+ else {
5409
+ let keyFn = typeof key === "string" ? item => item[key] : key;
5410
+ const eq = keyFn(state);
5411
+ if (eq !== undefined && keyFn(value) !== eq) {
5412
+ if (!replace) throw new Error("Cannot reconcile states with different identity");
5413
+ // Or the outgoing raw keeps resolving to this proxy and surfaces the
5414
+ // incoming entity wherever it reappears in this family.
5415
+ const t = state[$TARGET];
5416
+ if (t && t[STORE_VALUE] !== unwrap(value)) t[STORE_LOOKUP]?.delete(t[STORE_VALUE]);
5417
+ keyFn = IDENTITY;
5418
+ }
5419
+ applyState(value, state, keyFn);
5420
+ }
5421
+ if (chain !== undefined) target[STORE_VALUE] = chain;
5422
+ }
5218
5423
  /**
5219
5424
  * Returns a draft-mutating function that smart-merges `value` into a store,
5220
5425
  * preserving fine-grained reactivity: only changed leaves trigger updates.
@@ -5229,6 +5434,9 @@ const NOKEY = () => null;
5229
5434
  * the classic pattern for fixed-shape data that churns in place (dashboards,
5230
5435
  * monitors), where no keyed diff pass is needed or wanted.
5231
5436
  *
5437
+ * Merging into a slot that holds a *different* entity throws — the caller
5438
+ * picked the slot, so a key mismatch there is a bug.
5439
+ *
5232
5440
  * @param value the next state to merge in
5233
5441
  * @param key property name (string) or extractor function for stable
5234
5442
  * identity (default `"id"`); pass `null` for positional merging
@@ -5247,18 +5455,7 @@ const NOKEY = () => null;
5247
5455
  * ```
5248
5456
  */
5249
5457
  function reconcile(value, key = "id") {
5250
- return state => {
5251
- if (state == null) throw new Error("Cannot reconcile null or undefined state");
5252
- if (key === null) {
5253
- applyState(value, state, NOKEY);
5254
- return;
5255
- }
5256
- const keyFn = typeof key === "string" ? item => item[key] : key;
5257
- const eq = keyFn(state);
5258
- if (eq !== undefined && keyFn(value) !== eq)
5259
- throw new Error("Cannot reconcile states with different identity");
5260
- applyState(value, state, keyFn);
5261
- };
5458
+ return state => reconcileState(value, state, key, false);
5262
5459
  }
5263
5460
 
5264
5461
  function createProjectionInternal(fn, seed, options) {
@@ -5292,7 +5489,7 @@ function createProjectionInternal(fn, seed, options) {
5292
5489
  node = computed(
5293
5490
  () => {
5294
5491
  if (!node) node = getOwner();
5295
- runProjectionComputed(wrappedStore, fn, options?.key || "id");
5492
+ runProjectionComputed(wrappedStore, fn, options?.key === undefined ? "id" : options.key);
5296
5493
  },
5297
5494
  options?.name ? { name: options.name } : undefined
5298
5495
  );
@@ -5307,6 +5504,10 @@ function createProjectionInternal(fn, seed, options) {
5307
5504
  * items keep their proxy identity — only added/removed items are
5308
5505
  * created/disposed.
5309
5506
  *
5507
+ * If the derive returns a different entity than the one currently held (the
5508
+ * `/users/1` → `/users/2` shape), the store swaps to it rather than merging,
5509
+ * and nothing below it is treated as surviving.
5510
+ *
5310
5511
  * Returns the projected store directly (no setter — reads only).
5311
5512
  *
5312
5513
  * Use this when you want the structural-sharing / per-property tracking
@@ -5319,7 +5520,8 @@ function createProjectionInternal(fn, seed, options) {
5319
5520
  * @param seed the backing store value to wrap and reconcile into
5320
5521
  * @param options `ProjectionOptions` — `name`, `key`. `key` defaults to
5321
5522
  * `"id"`; specify it only when your data uses a different identity field
5322
- * (e.g. `{ key: "uuid" }` or `{ key: u => u.slug }`).
5523
+ * (e.g. `{ key: "uuid" }` or `{ key: u => u.slug }`), or `null` to merge
5524
+ * positionally with no keyed pass.
5323
5525
  *
5324
5526
  * @example
5325
5527
  * ```ts
@@ -5371,7 +5573,7 @@ function runProjectionComputed(wrappedStore, fn, key, wrapCommit, onDraftWrite)
5371
5573
  settled = true;
5372
5574
  const commit = v => {
5373
5575
  if (v === s || v === undefined) return;
5374
- const write = () => storeSetter(wrappedStore, reconcile(v, key));
5576
+ const write = () => storeSetter(wrappedStore, s => reconcileState(v, s, key, true));
5375
5577
  wrapCommit ? wrapCommit(write) : write();
5376
5578
  };
5377
5579
  commit(handleAsync(owner, result, commit));
@@ -5543,6 +5745,14 @@ function isRawValue(value) {
5543
5745
  }
5544
5746
  function markRawOne(v) {
5545
5747
  if (isWrappable(v)) {
5748
+ // A store proxy is already tracked elsewhere: the shallow boundary passes
5749
+ // it through by reference (replaced, never edited — same slot semantics
5750
+ // as a raw) instead of claiming it raw. The sticky mark is global, so
5751
+ // marking a live proxy would make wrap() serve it verbatim through every
5752
+ // OTHER store too — downstream deep stores then captured it instead of
5753
+ // wrapping it in their own family, and their writes landed in the
5754
+ // upstream store's override layer (#2932).
5755
+ if (v[$TARGET] !== undefined) return;
5546
5756
  if (storeLookup.has(v))
5547
5757
  throw new Error(
5548
5758
  "shallow store: an ingested record is already tracked as a deep store — one value cannot present both wrapped and raw"
@@ -5596,11 +5806,38 @@ function wrapShallow(value) {
5596
5806
  markRawIngest(value);
5597
5807
  return p;
5598
5808
  }
5809
+ const OBJECT_PROTO = Object.prototype;
5810
+ // Per-prototype memo for the custom-proto branch of isWrappable: the verdict
5811
+ // is fully determined by the prototype (tag and Node lineage both live on
5812
+ // the chain), so each class pays the tag call once — not per read.
5813
+ const wrappableProtos = new WeakMap();
5599
5814
  function isWrappable(obj) {
5600
5815
  if (obj == null || typeof obj !== "object" || Object.isFrozen(obj)) return false;
5601
- // Dynamic Node check (kept dynamic so test/SSR overrides of `globalThis.Node`
5602
- // are observed at call time).
5603
- return typeof Node === "undefined" || !(obj instanceof Node);
5816
+ // Plain data and user class instances wrap; platform objects never do
5817
+ // (#2952). Native code brand-checks internal slots and throws through a
5818
+ // proxy (`Map.prototype.size`, `Date.prototype.getTime`, ...), so
5819
+ // collections and other built-ins can't honestly be stores — they get the
5820
+ // markRaw-children contract automatically: served raw, mutations land raw,
5821
+ // the property holding them still tracks (reassignment notifies). The tag
5822
+ // check separates them structurally: user classes stringify as
5823
+ // `[object Object]` while every native/host object carries its own brand
5824
+ // (`[object Map]`, `[object Date]`, `[object Headers]`, ...), including
5825
+ // subclasses, which inherit the tag. getPrototypeOf keeps the hot path
5826
+ // (plain and null-proto objects) intrinsic-only — no property lookup.
5827
+ const proto = Object.getPrototypeOf(obj);
5828
+ if (proto === OBJECT_PROTO || proto === null) return true;
5829
+ if (Array.isArray(obj)) return true;
5830
+ let wrappable = wrappableProtos.get(proto);
5831
+ if (wrappable === undefined) {
5832
+ wrappable =
5833
+ Object.prototype.toString.call(obj) === "[object Object]" &&
5834
+ // Dynamic Node check (kept dynamic so test/SSR overrides of
5835
+ // `globalThis.Node` are observed at call time): shimmed DOMs implement
5836
+ // nodes as plain user classes, which pass the tag check.
5837
+ (typeof Node === "undefined" || !(obj instanceof Node));
5838
+ wrappableProtos.set(proto, wrappable);
5839
+ }
5840
+ return wrappable;
5604
5841
  }
5605
5842
  let writeOverride = false;
5606
5843
  function setWriteOverride(value) {
@@ -6072,8 +6309,11 @@ function armOptimisticStoreWrite(target, store) {
6072
6309
  * concurrent actions writing disjoint keys must revert independently, exactly
6073
6310
  * like optimistic signal nodes do via the transition's _optimisticNodes.
6074
6311
  * `activeTransition` is the write's transaction (action() opens it before the
6075
- * body runs); null marks an ambient write that clears at plain flush end.
6076
- * Same-key writes across actions keep last-write-wins layer semantics.
6312
+ * body runs); null marks an ambient write, which clears at plain flush end
6313
+ * unless its flush's transition is blocked on the store's own in-flight truth
6314
+ * (pending firewall, #2951), in which case it rides that transaction to
6315
+ * settle. Same-key writes across actions keep last-write-wins layer
6316
+ * semantics.
6077
6317
  */
6078
6318
  function stampOptimisticOwner(target, overrideKey, property) {
6079
6319
  if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
@@ -6124,17 +6364,37 @@ function notifyStoreProperty(target, property, mode, value, prev, prevHas) {
6124
6364
  }
6125
6365
  let Writing = null;
6126
6366
  /**
6127
- * A derived store's seed is a draft for the derive function, never an
6128
- * observable value (#2897): until the firewall first resolves there is
6129
- * nothing to read, so every consumer path throws NotReady tracked reads
6130
- * through their node (core read()), and the untracked fall-throughs in the
6131
- * traps through this guard. Returning the seed leaked it; returning
6132
- * `undefined` would break non-nullable types. Callers exempt the firewall
6133
- * itself (the derive function works its own draft while uninitialized).
6367
+ * A derived store follows async memo rules (#2897 ruling): its seed is a
6368
+ * draft for the derive function, never an observable value, and an errored
6369
+ * derive is an error state, never a silent stale/seed serve. Until the
6370
+ * firewall first resolves there is nothing to read, so every consumer path
6371
+ * throws NotReady tracked reads through their node (core read()), and the
6372
+ * untracked fall-throughs in the traps through this guard. Returning the
6373
+ * seed leaked it; returning `undefined` would break non-nullable types.
6374
+ * Callers exempt the firewall itself (the derive function works its own
6375
+ * draft while uninitialized).
6376
+ *
6377
+ * Error rail: a firewall carrying STATUS_ERROR throws its error for every
6378
+ * late reader — memo parity, where read()'s error branch does the same for
6379
+ * plain computeds. Rejection clears STATUS_UNINITIALIZED at commit, so
6380
+ * without this check late readers silently got the seed while settle-time
6381
+ * subscribers saw the error.
6382
+ *
6383
+ * Loading rail: the veto requires the firewall to still be in flight, not
6384
+ * just flagged: STATUS_UNINITIALIZED's clear is deferred to batch commit,
6385
+ * so during the settle flush a firewall that has already recomputed — and
6386
+ * reconciled real values into STORE_VALUE — still carries the stale flag.
6387
+ * STATUS_PENDING is the live bit (it clears eagerly at settle, mirroring
6388
+ * core read()'s verdict), so gating on it stops the guard from throwing a
6389
+ * fresh NotReadyError that nothing would ever sweep. #2944: mapArray's
6390
+ * keyed diff reads items inside its internal owner (untracked by design)
6391
+ * in exactly this window, and the stale throw wedged <For> permanently.
6134
6392
  */
6135
- function throwIfUninitialized(target) {
6393
+ function throwIfUnreadable(target) {
6136
6394
  const firewall = target[STORE_FIREWALL];
6137
- if (firewall && firewall._statusFlags & STATUS_UNINITIALIZED)
6395
+ if (!firewall) return;
6396
+ const flags = firewall._statusFlags;
6397
+ if (flags & STATUS_ERROR || (flags & STATUS_UNINITIALIZED && flags & STATUS_PENDING))
6138
6398
  throw firewall._error ?? new NotReadyError(firewall);
6139
6399
  }
6140
6400
  const storeTraps = {
@@ -6281,7 +6541,14 @@ const storeTraps = {
6281
6541
  }
6282
6542
  // Untracked fall-through (tracked reads already threw via their node in
6283
6543
  // read(); the dev strictRead error above wins first for memo parity).
6284
- if (!selfRead) throwIfUninitialized(target);
6544
+ // Observer-present reads must NOT re-consult the flag here: during the
6545
+ // settle flush the firewall has recomputed (first values live on the
6546
+ // pending rail, served by read() above) but its UNINITIALIZED clear is
6547
+ // deferred to batch commit — vetoing read()'s verdict with the stale flag
6548
+ // threw a fresh NotReadyError for an already-settled source, which no
6549
+ // sweep would ever release (#2938: projection over an async store wedged
6550
+ // its Loading boundary on `undefined`).
6551
+ if (!selfRead && !getObserver()) throwIfUnreadable(target);
6285
6552
  return isWrappable(value) ? wrap(value, target) : value;
6286
6553
  },
6287
6554
  has(target, property) {
@@ -6301,7 +6568,7 @@ const storeTraps = {
6301
6568
  if (getObserver()) {
6302
6569
  return read(getNode(target, nodes, property, has));
6303
6570
  }
6304
- throwIfUninitialized(target);
6571
+ throwIfUnreadable(target);
6305
6572
  return has;
6306
6573
  },
6307
6574
  set(target, property, rawValue) {
@@ -6315,8 +6582,19 @@ const storeTraps = {
6315
6582
  const prevHas = prevLayer
6316
6583
  ? prevLayer[property] !== $DELETED
6317
6584
  : property in target[STORE_VALUE];
6318
- const value = unwrapStoreValue(rawValue);
6319
- if (target[STORE_SHALLOW] && isWrappable(value)) rawValues.add(value);
6585
+ // Shallow slots hold store proxies verbatim (pass-through reference,
6586
+ // never raw-marked see markRawOne/#2932); everything else unwraps
6587
+ // and marks as usual.
6588
+ const passThrough = !!target[STORE_SHALLOW] && rawValue?.[$TARGET] !== undefined;
6589
+ const value = passThrough ? rawValue : unwrapStoreValue(rawValue);
6590
+ if (target[STORE_SHALLOW] && !passThrough && isWrappable(value)) {
6591
+ // Flip the live gate too: a bare add was inert unless something else
6592
+ // had already marked a raw somewhere (wrap() checks rawValuesUsed
6593
+ // first), so the documented set-trap ingest mark silently no-oped in
6594
+ // apps whose only shallow data arrived through writes.
6595
+ rawValuesUsed = true;
6596
+ rawValues.add(value);
6597
+ }
6320
6598
  // Symbol-keyed writes on arrays are metadata, not index writes — never run
6321
6599
  // them through the numeric index/length machinery (`parseInt` on a symbol
6322
6600
  // throws). #2769
@@ -6457,7 +6735,7 @@ const storeTraps = {
6457
6735
  // path is exempt (like the get/has traps' writeOnly early returns):
6458
6736
  // the first landing's reconcile enumerates the store while
6459
6737
  // STATUS_UNINITIALIZED is still set — it IS the initialization.
6460
- if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUninitialized(target);
6738
+ if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUnreadable(target);
6461
6739
  }
6462
6740
  // Merge optimistic override with regular override for key enumeration
6463
6741
  let keys = getKeys(target[STORE_VALUE], target[STORE_OVERRIDE], false);
@@ -6699,7 +6977,26 @@ function createOptimisticStore(first, second, options) {
6699
6977
  // STORE_OPTIMISTIC take the engine's write path, so install it before any
6700
6978
  // node can be created.
6701
6979
  installOptimisticEngine();
6702
- GlobalQueue._clearOptimisticStores ||= clearOptimisticStores;
6980
+ if (!GlobalQueue._clearOptimisticStores) {
6981
+ GlobalQueue._clearOptimisticStores = clearOptimisticStores;
6982
+ // Store half of the engine's override blockage (#2951): signal-form
6983
+ // createOptimistic carries the pending async and the override on ONE node,
6984
+ // so transitionBlocked sees both; a derived optimistic STORE splits them —
6985
+ // the layer sits on store targets while the in-flight truth lives on the
6986
+ // firewall computed. Without this, the transition adopting a bare store
6987
+ // write settled in the same flush that started the refetch and its settle
6988
+ // consumed the layer mid-flight (follow-up writes then drafted from base,
6989
+ // clobbering instead of composing). Optimistic state clears when truth
6990
+ // lands or its transaction ends — never mid-refetch. Wrapped here (engine
6991
+ // is already installed above) so store-free apps never carry the check.
6992
+ const engineBlocked = GlobalQueue._transitionBlocked;
6993
+ GlobalQueue._transitionBlocked = transition => {
6994
+ for (const store of transition._optimisticStores) {
6995
+ if ((store[$TARGET]?.[STORE_FIREWALL]?._statusFlags ?? 0) & STATUS_PENDING) return true;
6996
+ }
6997
+ return engineBlocked(transition);
6998
+ };
6999
+ }
6703
7000
  const derived = typeof first === "function";
6704
7001
  // Plain form: the second slot carries options.
6705
7002
  if (!derived && options === undefined) options = second;
@@ -6856,7 +7153,7 @@ function createOptimisticProjectionInternal(fn, initialValue, options) {
6856
7153
  runProjectionComputed(
6857
7154
  wrappedStore,
6858
7155
  fn,
6859
- options?.key || "id",
7156
+ options?.key === undefined ? "id" : options.key,
6860
7157
  wrapCommit,
6861
7158
  clearProjectionOverride
6862
7159
  );