@solidjs/signals 2.0.0-beta.26 → 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 {
@@ -1738,6 +1758,36 @@ function releaseSettledDependents(el) {
1738
1758
  forEachDependent(el, visit);
1739
1759
  if (candidates) for (const node of candidates) releaseIfSettledUnobserved(node);
1740
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
+ }
1741
1791
  function settlePendingSource(el) {
1742
1792
  let scheduled = false;
1743
1793
  let released;
@@ -2249,6 +2299,10 @@ function recompute(el, create = false) {
2249
2299
  let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY);
2250
2300
  const hasOverride = el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING;
2251
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;
2252
2306
  // Re-ask classification lives in the verdict module; capture the flag before
2253
2307
  // the recompute wipes _flags below.
2254
2308
  const hadReask = (el._flags & REACTIVE_REASK) !== 0;
@@ -2431,6 +2485,13 @@ function recompute(el, create = false) {
2431
2485
  insertIntoHeapHeight(s._sub, queueFor(s._sub));
2432
2486
  }
2433
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);
2434
2495
  }
2435
2496
  currentOptimisticLane = prevLane;
2436
2497
  const needsPendingCommit =
@@ -2839,15 +2900,19 @@ function read(el) {
2839
2900
  throw owner._error;
2840
2901
  }
2841
2902
  }
2842
- 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) {
2843
2908
  // Only a genuine reactive re-read may retry an errored async source:
2844
2909
  // - tracking: owned/tracked scope only (never events / `untrack` / effect side-effect phase)
2845
2910
  // - !pendingCheckActive: an `isPending` probe observes the error, never refetches
2846
- // - el._time < clock: only on a later cycle than the one the error was found
2847
- if (tracking && !pendingCheckActive && el._time < clock) {
2848
- 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);
2849
2914
  return read(el);
2850
- } else throw el._error;
2915
+ } else throw owner._error;
2851
2916
  }
2852
2917
  if (snapshotCaptureActive && c && c._config & CONFIG_IN_SNAPSHOT_SCOPE) {
2853
2918
  const sv = el._snapshotValue;
@@ -4144,7 +4209,12 @@ function action(genFn) {
4144
4209
  ctx = currentTransition(ctx);
4145
4210
  const i = ctx._actions.indexOf(it);
4146
4211
  if (i >= 0) ctx._actions.splice(i, 1);
4147
- 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);
4148
4218
  schedule();
4149
4219
  failed ? reject(e) : resolve(v);
4150
4220
  };
@@ -4735,6 +4805,21 @@ function itemKey(item, keyFn) {
4735
4805
  function keyedMatch(a, b, keyFn) {
4736
4806
  return a === b || (isWrappable(a) && isWrappable(b) && keyFn(a) === keyFn(b));
4737
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
+ }
4738
4823
  // Array reconciliation updates the slots it visits, then swaps STORE_VALUE.
4739
4824
  // Previously tracked keys that are absent from `next` still need invalidating,
4740
4825
  // and `in` dependencies should follow the new value's membership. Use
@@ -4799,11 +4884,7 @@ function applyStateChild(next, prevRaw, target, keyFn) {
4799
4884
  // Reconcile a single array slot: recurse into a wrappable pair, otherwise replace
4800
4885
  // the node's value outright (covers object→primitive and primitive→object).
4801
4886
  function applyArrayItem(next, previous, target, node, keyFn) {
4802
- if (
4803
- isWrappable(next) &&
4804
- isWrappable(previous) &&
4805
- !(rawValuesUsed && (isRawValue(previous) || isRawValue(next)))
4806
- ) {
4887
+ if (recursablePair(previous, next)) {
4807
4888
  const wrapped = wrap(previous, target);
4808
4889
  node && setSignal(node, wrapped);
4809
4890
  applyState(next, wrapped, keyFn);
@@ -4997,7 +5078,7 @@ function applyStateFast(next, target, keyFn) {
4997
5078
  // the recursion dispatch entirely. Raw-marked values are leaves:
4998
5079
  // replace the slot node instead of recursing.
4999
5080
  if (item !== next[start]) {
5000
- if (rawValuesUsed && (isRawValue(item) || isRawValue(next[start]))) {
5081
+ if (!recursablePair(item, next[start])) {
5001
5082
  arrayNodes?.[start] && setSignal(arrayNodes[start], wrapValue(next[start], target));
5002
5083
  } else applyStateChild(next[start], item, target, keyFn);
5003
5084
  }
@@ -5059,11 +5140,7 @@ function applyStateFast(next, target, keyFn) {
5059
5140
  } else if (next.length) {
5060
5141
  for (let i = 0, len = next.length; i < len; i++) {
5061
5142
  const item = previous[i];
5062
- if (
5063
- isWrappable(item) &&
5064
- isWrappable(next[i]) &&
5065
- !(rawValuesUsed && (isRawValue(item) || isRawValue(next[i])))
5066
- ) {
5143
+ if (recursablePair(item, next[i])) {
5067
5144
  if (item !== next[i]) applyStateChild(next[i], item, target, keyFn);
5068
5145
  } else {
5069
5146
  if (item !== next[i]) changed = true;
@@ -5173,8 +5250,8 @@ function applyStateSlow(next, target, keyFn) {
5173
5250
  );
5174
5251
  start++
5175
5252
  ) {
5176
- if (isWrappable(item) && isWrappable(next[start]) && item !== next[start]) {
5177
- if (rawValuesUsed && (isRawValue(item) || isRawValue(next[start]))) {
5253
+ if (item !== next[start] && isWrappable(item) && isWrappable(next[start])) {
5254
+ if (!recursablePair(item, next[start])) {
5178
5255
  nodes?.[start] && setSignal(nodes[start], wrapValue(next[start], target));
5179
5256
  } else applyState(next[start], wrap(item, target), keyFn);
5180
5257
  }
@@ -5236,11 +5313,7 @@ function applyStateSlow(next, target, keyFn) {
5236
5313
  } else if (next.length) {
5237
5314
  for (let i = 0, len = next.length; i < len; i++) {
5238
5315
  const item = getOverrideValue(previous, override, i, optOverride);
5239
- if (
5240
- isWrappable(item) &&
5241
- isWrappable(next[i]) &&
5242
- !(rawValuesUsed && (isRawValue(item) || isRawValue(next[i])))
5243
- ) {
5316
+ if (recursablePair(item, next[i])) {
5244
5317
  if (item !== next[i]) applyState(next[i], wrap(item, target), keyFn);
5245
5318
  } else {
5246
5319
  if (item !== next[i]) changed = true;
@@ -5295,6 +5368,58 @@ function applyStateSlow(next, target, keyFn) {
5295
5368
  // No-key reconcile: every item reports "no key", which routes array diffs to
5296
5369
  // the positional branch and object descent to plain per-property merging.
5297
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
+ }
5298
5423
  /**
5299
5424
  * Returns a draft-mutating function that smart-merges `value` into a store,
5300
5425
  * preserving fine-grained reactivity: only changed leaves trigger updates.
@@ -5309,6 +5434,9 @@ const NOKEY = () => null;
5309
5434
  * the classic pattern for fixed-shape data that churns in place (dashboards,
5310
5435
  * monitors), where no keyed diff pass is needed or wanted.
5311
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
+ *
5312
5440
  * @param value the next state to merge in
5313
5441
  * @param key property name (string) or extractor function for stable
5314
5442
  * identity (default `"id"`); pass `null` for positional merging
@@ -5327,18 +5455,7 @@ const NOKEY = () => null;
5327
5455
  * ```
5328
5456
  */
5329
5457
  function reconcile(value, key = "id") {
5330
- return state => {
5331
- if (state == null) throw new Error("Cannot reconcile null or undefined state");
5332
- if (key === null) {
5333
- applyState(value, state, NOKEY);
5334
- return;
5335
- }
5336
- const keyFn = typeof key === "string" ? item => item[key] : key;
5337
- const eq = keyFn(state);
5338
- if (eq !== undefined && keyFn(value) !== eq)
5339
- throw new Error("Cannot reconcile states with different identity");
5340
- applyState(value, state, keyFn);
5341
- };
5458
+ return state => reconcileState(value, state, key, false);
5342
5459
  }
5343
5460
 
5344
5461
  function createProjectionInternal(fn, seed, options) {
@@ -5372,7 +5489,7 @@ function createProjectionInternal(fn, seed, options) {
5372
5489
  node = computed(
5373
5490
  () => {
5374
5491
  if (!node) node = getOwner();
5375
- runProjectionComputed(wrappedStore, fn, options?.key || "id");
5492
+ runProjectionComputed(wrappedStore, fn, options?.key === undefined ? "id" : options.key);
5376
5493
  },
5377
5494
  options?.name ? { name: options.name } : undefined
5378
5495
  );
@@ -5387,6 +5504,10 @@ function createProjectionInternal(fn, seed, options) {
5387
5504
  * items keep their proxy identity — only added/removed items are
5388
5505
  * created/disposed.
5389
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
+ *
5390
5511
  * Returns the projected store directly (no setter — reads only).
5391
5512
  *
5392
5513
  * Use this when you want the structural-sharing / per-property tracking
@@ -5399,7 +5520,8 @@ function createProjectionInternal(fn, seed, options) {
5399
5520
  * @param seed the backing store value to wrap and reconcile into
5400
5521
  * @param options `ProjectionOptions` — `name`, `key`. `key` defaults to
5401
5522
  * `"id"`; specify it only when your data uses a different identity field
5402
- * (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.
5403
5525
  *
5404
5526
  * @example
5405
5527
  * ```ts
@@ -5451,7 +5573,7 @@ function runProjectionComputed(wrappedStore, fn, key, wrapCommit, onDraftWrite)
5451
5573
  settled = true;
5452
5574
  const commit = v => {
5453
5575
  if (v === s || v === undefined) return;
5454
- const write = () => storeSetter(wrappedStore, reconcile(v, key));
5576
+ const write = () => storeSetter(wrappedStore, s => reconcileState(v, s, key, true));
5455
5577
  wrapCommit ? wrapCommit(write) : write();
5456
5578
  };
5457
5579
  commit(handleAsync(owner, result, commit));
@@ -5684,11 +5806,38 @@ function wrapShallow(value) {
5684
5806
  markRawIngest(value);
5685
5807
  return p;
5686
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();
5687
5814
  function isWrappable(obj) {
5688
5815
  if (obj == null || typeof obj !== "object" || Object.isFrozen(obj)) return false;
5689
- // Dynamic Node check (kept dynamic so test/SSR overrides of `globalThis.Node`
5690
- // are observed at call time).
5691
- 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;
5692
5841
  }
5693
5842
  let writeOverride = false;
5694
5843
  function setWriteOverride(value) {
@@ -6160,8 +6309,11 @@ function armOptimisticStoreWrite(target, store) {
6160
6309
  * concurrent actions writing disjoint keys must revert independently, exactly
6161
6310
  * like optimistic signal nodes do via the transition's _optimisticNodes.
6162
6311
  * `activeTransition` is the write's transaction (action() opens it before the
6163
- * body runs); null marks an ambient write that clears at plain flush end.
6164
- * 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.
6165
6317
  */
6166
6318
  function stampOptimisticOwner(target, overrideKey, property) {
6167
6319
  if (overrideKey === STORE_OPTIMISTIC_OVERRIDE)
@@ -6212,17 +6364,37 @@ function notifyStoreProperty(target, property, mode, value, prev, prevHas) {
6212
6364
  }
6213
6365
  let Writing = null;
6214
6366
  /**
6215
- * A derived store's seed is a draft for the derive function, never an
6216
- * observable value (#2897): until the firewall first resolves there is
6217
- * nothing to read, so every consumer path throws NotReady tracked reads
6218
- * through their node (core read()), and the untracked fall-throughs in the
6219
- * traps through this guard. Returning the seed leaked it; returning
6220
- * `undefined` would break non-nullable types. Callers exempt the firewall
6221
- * 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.
6222
6392
  */
6223
- function throwIfUninitialized(target) {
6393
+ function throwIfUnreadable(target) {
6224
6394
  const firewall = target[STORE_FIREWALL];
6225
- 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))
6226
6398
  throw firewall._error ?? new NotReadyError(firewall);
6227
6399
  }
6228
6400
  const storeTraps = {
@@ -6376,7 +6548,7 @@ const storeTraps = {
6376
6548
  // threw a fresh NotReadyError for an already-settled source, which no
6377
6549
  // sweep would ever release (#2938: projection over an async store wedged
6378
6550
  // its Loading boundary on `undefined`).
6379
- if (!selfRead && !getObserver()) throwIfUninitialized(target);
6551
+ if (!selfRead && !getObserver()) throwIfUnreadable(target);
6380
6552
  return isWrappable(value) ? wrap(value, target) : value;
6381
6553
  },
6382
6554
  has(target, property) {
@@ -6396,7 +6568,7 @@ const storeTraps = {
6396
6568
  if (getObserver()) {
6397
6569
  return read(getNode(target, nodes, property, has));
6398
6570
  }
6399
- throwIfUninitialized(target);
6571
+ throwIfUnreadable(target);
6400
6572
  return has;
6401
6573
  },
6402
6574
  set(target, property, rawValue) {
@@ -6563,7 +6735,7 @@ const storeTraps = {
6563
6735
  // path is exempt (like the get/has traps' writeOnly early returns):
6564
6736
  // the first landing's reconcile enumerates the store while
6565
6737
  // STATUS_UNINITIALIZED is still set — it IS the initialization.
6566
- if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUninitialized(target);
6738
+ if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUnreadable(target);
6567
6739
  }
6568
6740
  // Merge optimistic override with regular override for key enumeration
6569
6741
  let keys = getKeys(target[STORE_VALUE], target[STORE_OVERRIDE], false);
@@ -6805,7 +6977,26 @@ function createOptimisticStore(first, second, options) {
6805
6977
  // STORE_OPTIMISTIC take the engine's write path, so install it before any
6806
6978
  // node can be created.
6807
6979
  installOptimisticEngine();
6808
- 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
+ }
6809
7000
  const derived = typeof first === "function";
6810
7001
  // Plain form: the second slot carries options.
6811
7002
  if (!derived && options === undefined) options = second;
@@ -6962,7 +7153,7 @@ function createOptimisticProjectionInternal(fn, initialValue, options) {
6962
7153
  runProjectionComputed(
6963
7154
  wrappedStore,
6964
7155
  fn,
6965
- options?.key || "id",
7156
+ options?.key === undefined ? "id" : options.key,
6966
7157
  wrapCommit,
6967
7158
  clearProjectionOverride
6968
7159
  );