@solidjs/signals 2.0.0-beta.31 → 2.0.0-beta.33

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
@@ -980,6 +980,10 @@ function commitPendingNode(n) {
980
980
  // Set _modified for effects, but not for tracked effects (they handle their own scheduling)
981
981
  if (n._type && n._type !== EFFECT_TRACKED) n._modified = true;
982
982
  }
983
+ // The committed hold is the first observable answer for a loading-window
984
+ // node — the window closes here, not at compute time (#2990). Unconditional
985
+ // store to an always-present computed slot.
986
+ c._loading = false;
983
987
  c._flags &= ~REACTIVE_MANUAL_WRITE;
984
988
  if (!(c._statusFlags & STATUS_PENDING)) c._statusFlags &= ~STATUS_UNINITIALIZED;
985
989
  if (c._pendingFirstChild !== null || c._pendingDisposal !== null)
@@ -1327,6 +1331,10 @@ function markDisposal(el) {
1327
1331
  }
1328
1332
  }
1329
1333
  function dispose(node) {
1334
+ // Leave every scheduler heap on disposal (mirrors `unobserved`): a node
1335
+ // still queued here would be recomputed by the next flush, and recompute()
1336
+ // rewriting `_flags` would clear REACTIVE_DISPOSED — resurrecting it (#2983).
1337
+ deleteFromHeap(node, queueFor(node));
1330
1338
  let toRemove = node._deps;
1331
1339
  while (toRemove !== null) {
1332
1340
  toRemove = unlinkSubs(toRemove);
@@ -1353,9 +1361,14 @@ function disposeChildren(node, self = false, zombie) {
1353
1361
  let child = zombie ? node._pendingFirstChild : node._firstChild;
1354
1362
  while (child) {
1355
1363
  const nextChild = child._nextSibling;
1356
- if (child._deps) {
1357
- const n = child;
1358
- deleteFromHeap(n, queueFor(n));
1364
+ const n = child;
1365
+ // Heap removal must not be gated on `_deps`: a dependency-free
1366
+ // computation queued by refresh() has a null dep list but still sits in
1367
+ // the dirty heap, and left there the post-disposal flush recomputes it —
1368
+ // recompute() rewriting `_flags` clears REACTIVE_DISPOSED and the node
1369
+ // comes back to life (post-unmount runs, leaked cleanups, #2983).
1370
+ if (n._flags & (REACTIVE_IN_HEAP | REACTIVE_IN_HEAP_HEIGHT)) deleteFromHeap(n, queueFor(n));
1371
+ if (n._deps) {
1359
1372
  let toRemove = n._deps;
1360
1373
  do {
1361
1374
  toRemove = unlinkSubs(toRemove);
@@ -1736,6 +1749,34 @@ function clearPendingSources(el) {
1736
1749
  el._pendingSources?.clear();
1737
1750
  el._pendingSources = undefined;
1738
1751
  }
1752
+ // A rejection-pending only resolves through the settle sweep over the
1753
+ // SOURCE's subscribers, so it is retryable iff a tracked read created that
1754
+ // edge: a dep that IS the source, or one whose own pending chain carries it
1755
+ // (pending sources propagate the origin node, so this covers any depth).
1756
+ // Dev-only caller — tree-shaken from prod builds.
1757
+ function retryReaches(el, source) {
1758
+ for (let d = el._deps; d; d = d._nextDep) {
1759
+ const dep = d._dep._firewall || d._dep;
1760
+ if (dep === source || dep._pendingSources?.has(source)) return true;
1761
+ }
1762
+ return false;
1763
+ }
1764
+ /**
1765
+ * A loading-window node hit an unready source (sync throw in recompute, or a
1766
+ * NotReadyError-rejected flight): register for the source's settle — the
1767
+ * settlePendingSource walk runs off `_pendingSources` + `_blocked` alone —
1768
+ * with NO read-visible pending status, no downstream propagation, no
1769
+ * transition, no lane registration. Commit #0 keeps serving.
1770
+ */
1771
+ function parkLoadingWindow(el, e) {
1772
+ el._blocked = true;
1773
+ if (e.source) addPendingSource(el, e.source);
1774
+ // A settled error is the node's answer ("the error stays the answer until
1775
+ // this retry can actually run") — the park must not replace it: reads
1776
+ // throw `_error` while STATUS_ERROR is set, and overwriting it here leaks
1777
+ // a pending-class NotReadyError from a read-invisible park (#2989).
1778
+ if (!(el._statusFlags & STATUS_ERROR)) setPendingError(el, e.source, e);
1779
+ }
1739
1780
  function setPendingError(el, source, error) {
1740
1781
  if (!source) {
1741
1782
  el._error = null;
@@ -1832,12 +1873,18 @@ function settlePendingSource(el) {
1832
1873
  visited.add(node);
1833
1874
  node._time = clock;
1834
1875
  const remaining = node._pendingSources?.values().next().value;
1876
+ // STATUS_ERROR + pending sources only coexist via an errored loading
1877
+ // window's park (notifyStatus(STATUS_ERROR) clears pending sources
1878
+ // otherwise): the settled error stays the answer through the settle —
1879
+ // nulling it here would have reads throw `null` until the re-enqueued
1880
+ // retry lands, or lose it entirely if that retry parks again (#2989).
1881
+ const errored = node._statusFlags & STATUS_ERROR;
1835
1882
  if (remaining) {
1836
- setPendingError(node, remaining);
1883
+ if (!errored) setPendingError(node, remaining);
1837
1884
  updateCompanions !== null && updateCompanions(node);
1838
1885
  } else {
1839
1886
  node._statusFlags &= ~STATUS_PENDING;
1840
- setPendingError(node);
1887
+ if (!errored) setPendingError(node);
1841
1888
  updateCompanions !== null && updateCompanions(node);
1842
1889
  if (node._blocked) {
1843
1890
  enqueueSub(node);
@@ -1872,6 +1919,8 @@ function handleAsync(el, result, setter) {
1872
1919
  }
1873
1920
  if (!thenable && !iterator) {
1874
1921
  el._inFlight = null;
1922
+ // A sync landing is the first real answer for a loadingValue node.
1923
+ el._loading = false;
1875
1924
  return result;
1876
1925
  }
1877
1926
  // Dev-only contract enforcement for `sync: true` nodes. In production these
@@ -1924,9 +1973,36 @@ function handleAsync(el, result, setter) {
1924
1973
  };
1925
1974
  const handleError = error => {
1926
1975
  if (el._inFlight !== result) return;
1927
- settleTransition();
1928
1976
  // NotReadyError from rejected promises should be treated as pending, not error
1929
- const stillPending = error instanceof NotReadyError;
1977
+ let stillPending = error instanceof NotReadyError;
1978
+ // Dev-only authorship diagnostic (#2987): no edge means a post-`await`
1979
+ // FIRST read — untracked, so the source's settle sweep can never find
1980
+ // this node and "pending" wedges it (and its boundary) forever while
1981
+ // isPending reads false. Fail loud in dev; prod pays no bytes for the
1982
+ // forbidden pattern (the wedge stands there, caught during development).
1983
+ // Runs BEFORE the loading-window parking below: a non-retryable read is
1984
+ // a real error, and the window must not silently park a wedge that can
1985
+ // never settle.
1986
+ if (stillPending && !retryReaches(el, error.source)) {
1987
+ stillPending = false;
1988
+ error = new Error(
1989
+ "Read of an unresolved async source after an `await`. Reads inside async " +
1990
+ "computations only register as dependencies before the first `await`; a source " +
1991
+ "first read after it cannot retry when it settles. Read it before the first " +
1992
+ "`await` (or restructure so the value is an input)."
1993
+ );
1994
+ }
1995
+ if (stillPending && el._loading) {
1996
+ // Loading window: the flight died waiting on an unready source. Keep
1997
+ // serving commit #0 — same parking as recompute's catch for sync
1998
+ // dependency throws. The dead flight is released so the clock-gated
1999
+ // error-retry pull (updateIfNecessary) can also re-ask.
2000
+ el._inFlight = null;
2001
+ parkLoadingWindow(el, error);
2002
+ el._time = clock;
2003
+ return;
2004
+ }
2005
+ settleTransition();
1930
2006
  notifyStatus(el, stillPending ? STATUS_PENDING : STATUS_ERROR, error);
1931
2007
  el._time = clock;
1932
2008
  // A real error settles derivatively-pending dependents (notifyStatus
@@ -2002,6 +2078,13 @@ function handleAsync(el, result, setter) {
2002
2078
  notifyStatus(el, STATUS_ERROR, e);
2003
2079
  }
2004
2080
  }
2081
+ // First real answer landing: the window closes when the answer becomes
2082
+ // OBSERVABLE. A direct commit is observable now; a transition-held write
2083
+ // (`_pendingValue` set above or inside setSignal) is not — the verdict's
2084
+ // held-value branch is window-gated, and commitPendingNode closes the
2085
+ // window when the hold commits, so no one-frame isPending pulse can leak
2086
+ // to live observers between the landing and its commit (#2990).
2087
+ if (el._pendingValue === NOT_PENDING) el._loading = false;
2005
2088
  settlePendingSource(el);
2006
2089
  schedule();
2007
2090
  flush();
@@ -2054,8 +2137,16 @@ function handleAsync(el, result, setter) {
2054
2137
  handleError(syncError);
2055
2138
  throw syncError;
2056
2139
  } else if (!resolved) {
2140
+ // Loading window: serve commit #0 instead of suspending. No transition
2141
+ // is opened — first-flight work on a loadingValue node is loading-class
2142
+ // (invisible to boundaries and transitions); the flight itself is
2143
+ // already registered in _inFlight and lands through asyncWrite.
2144
+ if (el._loading) return el._value;
2057
2145
  globalQueue.initTransition(resolveTransition(el));
2058
2146
  throw new NotReadyError(context);
2147
+ } else {
2148
+ // Synchronously-resolved promise: the first real answer landed.
2149
+ el._loading = false;
2059
2150
  }
2060
2151
  }
2061
2152
  if (iterator) {
@@ -2083,9 +2174,22 @@ function handleAsync(el, result, setter) {
2083
2174
  resolved = false,
2084
2175
  rejected = false,
2085
2176
  isSync = true;
2086
- it.next().then(
2177
+ // Protocol tolerance, matching `for await`: `await` unwraps whatever
2178
+ // next() returns — a thenable OR a bare IteratorResult. Real producers
2179
+ // use the bare form as a promise-free fast path when a value is already
2180
+ // buffered (seroval's deserialized streams do), so a bare result is
2181
+ // assimilated as an already-settled step instead of crashing on `.then`.
2182
+ const step = it.next();
2183
+ const settled = isThenable(step) ? step : { then: onSettle => void onSettle(step) };
2184
+ settled.then(
2087
2185
  r => {
2088
- if (isSync) {
2186
+ // The sync stash only serves the INITIAL drain (handleAsync's caller
2187
+ // consumes syncValue / throws NotReady from it). A sync-settled step
2188
+ // after an async gap — seroval buffering values between pulls, a
2189
+ // sync-thenable producer mid-stream — has no caller reading the
2190
+ // stash: it must write through the async path or the value is
2191
+ // silently dropped.
2192
+ if (isSync && initialRead) {
2089
2193
  syncResult = r;
2090
2194
  resolved = true;
2091
2195
  if (r.done) completed = true;
@@ -2136,9 +2240,14 @@ function handleAsync(el, result, setter) {
2136
2240
  // Later iterate() calls run from asyncWrite, where rethrowing would be unhandled.
2137
2241
  initialRead = false;
2138
2242
  if (!hadValue && !immediatelyDone) {
2243
+ // Loading window: serve commit #0 (see the promise branch above).
2244
+ if (el._loading) return el._value;
2139
2245
  globalQueue.initTransition(resolveTransition(el));
2140
2246
  throw new NotReadyError(context);
2141
2247
  }
2248
+ // A sync first yield (or immediate empty completion) is the first real
2249
+ // answer; async yields clear inside asyncWrite.
2250
+ el._loading = false;
2142
2251
  }
2143
2252
  return syncValue;
2144
2253
  }
@@ -2339,6 +2448,11 @@ function recompute(el, create = false) {
2339
2448
  // Re-ask classification lives in the verdict module; capture the flag before
2340
2449
  // the recompute wipes _flags below.
2341
2450
  const hadReask = (el._flags & REACTIVE_REASK) !== 0;
2451
+ // Captured before the compute clears it on a sync landing: if that landing
2452
+ // is transition-held below, the window must stay open until the hold
2453
+ // commits (commitPendingNode) — a closed window plus a held value reads as
2454
+ // a pending frame to live observers of the verdict (#2990).
2455
+ const wasLoading = el._loading;
2342
2456
  const oldcontext = context;
2343
2457
  context = el;
2344
2458
  el._depsTail = null;
@@ -2395,7 +2509,14 @@ function recompute(el, create = false) {
2395
2509
  const isAsyncResult = typeof fnResult === "object" && fnResult !== null;
2396
2510
  const inFlightChanged = el._inFlight !== prevInFlight;
2397
2511
  value = inFlightChanged || !isAsyncResult ? fnResult : handleAsync(el, fnResult);
2398
- if (!inFlightChanged && !isAsyncResult) el._inFlight = null;
2512
+ if (!inFlightChanged && !isAsyncResult) {
2513
+ el._inFlight = null;
2514
+ // A sync (non-object) return is the first real answer; async-shaped
2515
+ // results clear inside handleAsync at their own landing points, and a
2516
+ // self-registered flight (inFlightChanged — projections) clears when
2517
+ // its internal handleAsync lands.
2518
+ el._loading = false;
2519
+ }
2399
2520
  }
2400
2521
  // On a status-free node clearStatus is a guaranteed no-op: every branch
2401
2522
  // in its body is gated on one of these fields, and with _statusFlags === 0
@@ -2415,22 +2536,34 @@ function recompute(el, create = false) {
2415
2536
  // _optimisticLane is only ever assigned by engine paths.
2416
2537
  if (el._optimisticLane) GlobalQueue._laneAsyncSettled(el);
2417
2538
  } catch (e) {
2418
- // Track pending async in the lane (not the lane's source — it creates the lane
2419
- // but doesn't belong to it). Set lane BEFORE notifyStatus for downstream propagation.
2420
- if (e instanceof NotReadyError && currentOptimisticLane) GlobalQueue._laneAsyncPending(el);
2421
- let reaskChanged = false;
2422
- if (e instanceof NotReadyError) {
2423
- el._blocked = true;
2424
- if (GlobalQueue._applyReask !== null) reaskChanged = GlobalQueue._applyReask(el, hadReask);
2425
- }
2426
- notifyStatus(
2427
- el,
2428
- e instanceof NotReadyError ? STATUS_PENDING : STATUS_ERROR,
2429
- e,
2430
- undefined,
2431
- e instanceof NotReadyError ? el._optimisticLane : undefined
2432
- );
2433
- if (reaskChanged) GlobalQueue._repollVerdicts(el);
2539
+ const notReady = e instanceof NotReadyError;
2540
+ if (notReady && el._loading) {
2541
+ // Loading window with an unready sync dependency: register for the
2542
+ // source's settle (the settlePendingSource walk runs off
2543
+ // _pendingSources + _blocked alone) but take NO read-visible pending
2544
+ // status, no downstream propagation, no transition, no lane
2545
+ // registration the committed loading value keeps serving. If the
2546
+ // node is currently errored the error stays the answer until this
2547
+ // retry can actually run.
2548
+ parkLoadingWindow(el, e);
2549
+ } else {
2550
+ // Track pending async in the lane (not the lane's source — it creates the lane
2551
+ // but doesn't belong to it). Set lane BEFORE notifyStatus for downstream propagation.
2552
+ if (notReady && currentOptimisticLane) GlobalQueue._laneAsyncPending(el);
2553
+ let reaskChanged = false;
2554
+ if (notReady) {
2555
+ el._blocked = true;
2556
+ if (GlobalQueue._applyReask !== null) reaskChanged = GlobalQueue._applyReask(el, hadReask);
2557
+ }
2558
+ notifyStatus(
2559
+ el,
2560
+ notReady ? STATUS_PENDING : STATUS_ERROR,
2561
+ e,
2562
+ undefined,
2563
+ notReady ? el._optimisticLane : undefined
2564
+ );
2565
+ if (reaskChanged) GlobalQueue._repollVerdicts(el);
2566
+ }
2434
2567
  } finally {
2435
2568
  tracking = prevTracking;
2436
2569
  latestReadActive = prevLatestRead;
@@ -2492,6 +2625,9 @@ function recompute(el, create = false) {
2492
2625
  }
2493
2626
  } else {
2494
2627
  el._pendingValue = value;
2628
+ // A window landing that gets held re-opens the window until the hold
2629
+ // commits — the verdict's held-value branch is window-gated (#2990).
2630
+ if (wasLoading) el._loading = true;
2495
2631
  // Transition-held sync recompute is a write path like setSignal/asyncWrite,
2496
2632
  // so sync derivations of held sources stay visible to isPending()/latest()
2497
2633
  // (#2831). Both companion writes are transition-scoped (optimistic) and
@@ -2513,6 +2649,7 @@ function recompute(el, create = false) {
2513
2649
  // for commit on its own transition's schedule — invisibly (A17/A18).
2514
2650
  if (el._pendingValue === NOT_PENDING) queuePendingNode(el);
2515
2651
  el._pendingValue = value;
2652
+ if (wasLoading) el._loading = true; // see the held branch above (#2990)
2516
2653
  } else if (el._height != oldHeight) {
2517
2654
  for (let s = el._subs; s !== null; s = s._nextSub) {
2518
2655
  insertIntoHeapHeight(s._sub, queueFor(s._sub));
@@ -2569,6 +2706,10 @@ function updateIfNecessary(el) {
2569
2706
  }
2570
2707
  function computed(fn, options) {
2571
2708
  const transparent = options?.transparent ?? false;
2709
+ // `in` (not `!== undefined`): an explicit `loadingValue: undefined` on a
2710
+ // `T | undefined` node is a real commit #0. The typeof guard tolerates
2711
+ // non-object option values that older call shapes force through `as any`.
2712
+ const loading = options !== null && typeof options === "object" && "loadingValue" in options;
2572
2713
  const self = {
2573
2714
  id: inheritId(options, transparent, context),
2574
2715
  _config:
@@ -2585,7 +2726,7 @@ function computed(fn, options) {
2585
2726
  _context: context?._context ?? defaultContext,
2586
2727
  _childCount: 0,
2587
2728
  _fn: fn,
2588
- _value: undefined,
2729
+ _value: loading ? options.loadingValue : undefined,
2589
2730
  _height: 0,
2590
2731
  _child: null,
2591
2732
  _nextHeap: undefined,
@@ -2600,14 +2741,16 @@ function computed(fn, options) {
2600
2741
  _prevSibling: null,
2601
2742
  _firstChild: null,
2602
2743
  _flags: options?.lazy ? REACTIVE_LAZY : REACTIVE_NONE,
2603
- _statusFlags: STATUS_UNINITIALIZED,
2744
+ // A loadingValue node is born committed: commit #0 is already in _value.
2745
+ _statusFlags: loading ? 0 : STATUS_UNINITIALIZED,
2604
2746
  _time: clock,
2605
2747
  _pendingValue: NOT_PENDING,
2606
2748
  _pendingDisposal: null,
2607
2749
  _pendingFirstChild: null,
2608
2750
  _inFlight: null,
2609
2751
  _transition: null,
2610
- _reask: false
2752
+ _reask: false,
2753
+ _loading: loading
2611
2754
  };
2612
2755
  self._name = options?.name ?? "computed";
2613
2756
  setupComputedNode(self, options);
@@ -2658,6 +2801,7 @@ function createEffectNode(fn, effectFn, errorFn, type, notifyStatus, options) {
2658
2801
  _inFlight: null,
2659
2802
  _transition: null,
2660
2803
  _reask: false,
2804
+ _loading: false,
2661
2805
  _modified: false,
2662
2806
  _prevValue: undefined,
2663
2807
  _effectFn: effectFn,
@@ -3685,6 +3829,13 @@ function quietPending(el) {
3685
3829
  }
3686
3830
  return el._reask;
3687
3831
  }
3832
+ // NOTE: a loadingValue node's open loading window (_loading) is verdict-quiet
3833
+ // on purpose: commit #0 answers the question by declaration, so the window
3834
+ // reads NOT pending — first-load affordances live in the value channel
3835
+ // (null / skeleton provenance the author encoded), and isPending stays what
3836
+ // it always was: refetch truth for an answered question. This keeps the
3837
+ // verdict fully correlated with transition-class machinery and keeps server
3838
+ // (always false) and client hydration trivially consistent.
3688
3839
  function newQuestionInFlight(comp) {
3689
3840
  return (
3690
3841
  !!(comp._statusFlags & STATUS_PENDING) &&
@@ -3712,7 +3863,15 @@ function computePendingState(el) {
3712
3863
  (!!(firewall._statusFlags & STATUS_PENDING) && quietPending(firewall))
3713
3864
  );
3714
3865
  }
3715
- if (el._pendingValue !== NOT_PENDING && !(comp._statusFlags & STATUS_UNINITIALIZED)) {
3866
+ // `!comp._loading`: a hold created while the loading window is still open is
3867
+ // the window's own landing in flight to its commit — verdict-quiet like the
3868
+ // rest of the window (the UNINITIALIZED check suppresses exactly this frame
3869
+ // for windowless first loads; born-committed nodes need their own gate, #2990).
3870
+ if (
3871
+ el._pendingValue !== NOT_PENDING &&
3872
+ !(comp._statusFlags & STATUS_UNINITIALIZED) &&
3873
+ !comp._loading
3874
+ ) {
3716
3875
  if (hasActiveOverride(el))
3717
3876
  return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._overrideValue));
3718
3877
  return true;
@@ -4396,38 +4555,6 @@ function createSignal(first, second) {
4396
4555
  registerGraph(node, getOwner());
4397
4556
  return [accessor(node), setSignal.bind(null, node)];
4398
4557
  }
4399
- /**
4400
- * Creates a readonly derived reactive memoized signal.
4401
- *
4402
- * ```typescript
4403
- * const value = createMemo<T>(compute, options?: MemoOptions<T>);
4404
- * ```
4405
- * @param compute a function that receives its previous value and returns a new value used to react on a computation
4406
- * @param options `MemoOptions` -- id, name, equals, unobserved, lazy
4407
- *
4408
- * @example
4409
- * ```ts
4410
- * const [first, setFirst] = createSignal("Ada");
4411
- * const [last, setLast] = createSignal("Lovelace");
4412
- *
4413
- * const fullName = createMemo(() => `${first()} ${last()}`);
4414
- *
4415
- * fullName(); // "Ada Lovelace"
4416
- * ```
4417
- *
4418
- * @example
4419
- * ```ts
4420
- * // Async memo — reads surface as pending inside <Loading>
4421
- * const user = createMemo(async () => {
4422
- * const res = await fetch(`/users/${id()}`);
4423
- * return res.json();
4424
- * });
4425
- * ```
4426
- *
4427
- * @description https://docs.solidjs.com/reference/basic-reactivity/create-memo
4428
- */
4429
- // NoInfer keeps the previous-value parameter from influencing T inference, so
4430
- // the memo/effect result type is still driven by the compute return type.
4431
4558
  function createMemo(compute, options) {
4432
4559
  return accessor(computed(compute, options));
4433
4560
  }
@@ -4465,7 +4592,7 @@ function createEffect(compute, effectFn, options) {
4465
4592
  * ```
4466
4593
  * @param compute a function that receives its previous value and returns a new value used to react on a computation
4467
4594
  * @param effectFn a function that receives the new value and is used to perform side effects
4468
- * @param options `EffectOptions` -- name, defer, schedule
4595
+ * @param options `EffectOptions` -- name, defer, schedule, transparent
4469
4596
  *
4470
4597
  * @example
4471
4598
  * ```ts
@@ -5532,13 +5659,17 @@ function createProjectionInternal(fn, seed, options) {
5532
5659
  return wrapped;
5533
5660
  };
5534
5661
  const wrappedStore = wrapProjection(seed);
5535
- node = computed(
5536
- () => {
5537
- if (!node) node = getOwner();
5538
- runProjectionComputed(wrappedStore, fn, options?.key === undefined ? "id" : options.key);
5539
- },
5540
- options?.name ? { name: options.name } : undefined
5541
- );
5662
+ // seedLoadingValue: the firewall is born committed (the seed is commit #0);
5663
+ // the internal handleAsync serves it during the derive's first flight. The
5664
+ // node's own value channel is void, so the loading value itself is
5665
+ // `undefined` — presence of the key is what flips the mode.
5666
+ let nodeOptions;
5667
+ if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined };
5668
+ if (options?.name) nodeOptions = { ...nodeOptions, name: options.name };
5669
+ node = computed(() => {
5670
+ if (!node) node = getOwner();
5671
+ runProjectionComputed(wrappedStore, fn, options?.key === undefined ? "id" : options.key);
5672
+ }, nodeOptions);
5542
5673
  node._config &= ~CONFIG_AUTO_DISPOSE;
5543
5674
  return { store: wrappedStore, node };
5544
5675
  }
@@ -5610,19 +5741,48 @@ function runProjectionComputed(wrappedStore, fn, key, wrapCommit, onDraftWrite)
5610
5741
  const owner = getOwner();
5611
5742
  let settled = false;
5612
5743
  let result;
5744
+ // Open loading window (seedLoadingValue): the observable store IS commit #0
5745
+ // for the whole first flight, so the derive works a detached shadow of the
5746
+ // seed — draft writes (pre-await, or between yields) land on the shadow and
5747
+ // cannot tear through to readers (#2988; store reads resolve from the live
5748
+ // backing, and the born-committed firewall removed the status gate that hid
5749
+ // windowless drafts). Every commit point — sync return, each yield, the
5750
+ // async landing — reconciles the shadow through the normal commit path, so
5751
+ // a fully-sync derive still lands immediately (commit #0 superseded before
5752
+ // any observer runs, same as a sync answer superseding loadingValue). The
5753
+ // JSON round-trip matches the server's frozen-seed copy (seedLock): a
5754
+ // loading-window seed is renderable data by contract. Optimistic note:
5755
+ // onDraftWrite (override clearing) shifts from write-time to commit-time
5756
+ // for the shadow run — an invisible draft write must not clobber a visible
5757
+ // optimistic override mid-window.
5758
+ const shadow = owner._loading
5759
+ ? JSON.parse(JSON.stringify(wrappedStore[$TARGET][STORE_VALUE]))
5760
+ : null;
5613
5761
  const draft = new Proxy(
5614
5762
  wrappedStore,
5615
5763
  createWriteTraps(() => !settled || owner._inFlight === result, onDraftWrite)
5616
5764
  );
5617
5765
  storeSetter(draft, s => {
5618
- result = fn(s);
5766
+ result = fn(shadow ?? s);
5619
5767
  settled = true;
5620
5768
  const commit = v => {
5769
+ // Shadow run: a void/self return is the mutation form — the shadow
5770
+ // carries the writes and is what commits. Commit a detached snapshot,
5771
+ // never the shadow itself: reconcile adopts a new root value by
5772
+ // identity, and handing it the live shadow would fuse the draft to the
5773
+ // observable store — later shadow writes would mutate the backing
5774
+ // silently and the next yield would diff the shadow against itself.
5775
+ if (shadow && (v === undefined || v === shadow)) v = JSON.parse(JSON.stringify(shadow));
5621
5776
  if (v === s || v === undefined) return;
5622
5777
  const write = () => storeSetter(wrappedStore, s => reconcileState(v, s, key, true));
5623
5778
  wrapCommit ? wrapCommit(write) : write();
5624
5779
  };
5625
- commit(handleAsync(owner, result, commit));
5780
+ const sync = handleAsync(owner, result, commit);
5781
+ // A still-open window after handleAsync means the return was the
5782
+ // commit-#0 fall-through, not a landing — real landings arrive through
5783
+ // the setter. A closed one is a genuine sync landing (windowless nodes
5784
+ // were never open); commit it.
5785
+ if (!owner._loading) commit(sync);
5626
5786
  });
5627
5787
  return owner;
5628
5788
  }
@@ -7192,23 +7352,24 @@ function createOptimisticProjectionInternal(fn, initialValue, options) {
7192
7352
  setProjectionWriteActive(wasProjectionWriteActive);
7193
7353
  }
7194
7354
  };
7195
- node = computed(
7196
- () => {
7197
- setProjectionWriteActive(true);
7198
- try {
7199
- runProjectionComputed(
7200
- wrappedStore,
7201
- fn,
7202
- options?.key === undefined ? "id" : options.key,
7203
- wrapCommit,
7204
- clearProjectionOverride
7205
- );
7206
- } finally {
7207
- setProjectionWriteActive(false);
7208
- }
7209
- },
7210
- options?.name ? { name: options.name } : undefined
7211
- );
7355
+ // seedLoadingValue: born-committed firewall, same as createProjection.
7356
+ let nodeOptions;
7357
+ if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined };
7358
+ if (options?.name) nodeOptions = { ...nodeOptions, name: options.name };
7359
+ node = computed(() => {
7360
+ setProjectionWriteActive(true);
7361
+ try {
7362
+ runProjectionComputed(
7363
+ wrappedStore,
7364
+ fn,
7365
+ options?.key === undefined ? "id" : options.key,
7366
+ wrapCommit,
7367
+ clearProjectionOverride
7368
+ );
7369
+ } finally {
7370
+ setProjectionWriteActive(false);
7371
+ }
7372
+ }, nodeOptions);
7212
7373
  node._config &= ~CONFIG_AUTO_DISPOSE;
7213
7374
  }
7214
7375
  return { store: wrappedStore, node };