@solidjs/signals 2.0.0-beta.32 → 2.0.0-beta.34

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) {
@@ -2149,9 +2240,14 @@ function handleAsync(el, result, setter) {
2149
2240
  // Later iterate() calls run from asyncWrite, where rethrowing would be unhandled.
2150
2241
  initialRead = false;
2151
2242
  if (!hadValue && !immediatelyDone) {
2243
+ // Loading window: serve commit #0 (see the promise branch above).
2244
+ if (el._loading) return el._value;
2152
2245
  globalQueue.initTransition(resolveTransition(el));
2153
2246
  throw new NotReadyError(context);
2154
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;
2155
2251
  }
2156
2252
  return syncValue;
2157
2253
  }
@@ -2352,6 +2448,11 @@ function recompute(el, create = false) {
2352
2448
  // Re-ask classification lives in the verdict module; capture the flag before
2353
2449
  // the recompute wipes _flags below.
2354
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;
2355
2456
  const oldcontext = context;
2356
2457
  context = el;
2357
2458
  el._depsTail = null;
@@ -2408,7 +2509,14 @@ function recompute(el, create = false) {
2408
2509
  const isAsyncResult = typeof fnResult === "object" && fnResult !== null;
2409
2510
  const inFlightChanged = el._inFlight !== prevInFlight;
2410
2511
  value = inFlightChanged || !isAsyncResult ? fnResult : handleAsync(el, fnResult);
2411
- 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
+ }
2412
2520
  }
2413
2521
  // On a status-free node clearStatus is a guaranteed no-op: every branch
2414
2522
  // in its body is gated on one of these fields, and with _statusFlags === 0
@@ -2428,22 +2536,34 @@ function recompute(el, create = false) {
2428
2536
  // _optimisticLane is only ever assigned by engine paths.
2429
2537
  if (el._optimisticLane) GlobalQueue._laneAsyncSettled(el);
2430
2538
  } catch (e) {
2431
- // Track pending async in the lane (not the lane's source — it creates the lane
2432
- // but doesn't belong to it). Set lane BEFORE notifyStatus for downstream propagation.
2433
- if (e instanceof NotReadyError && currentOptimisticLane) GlobalQueue._laneAsyncPending(el);
2434
- let reaskChanged = false;
2435
- if (e instanceof NotReadyError) {
2436
- el._blocked = true;
2437
- if (GlobalQueue._applyReask !== null) reaskChanged = GlobalQueue._applyReask(el, hadReask);
2438
- }
2439
- notifyStatus(
2440
- el,
2441
- e instanceof NotReadyError ? STATUS_PENDING : STATUS_ERROR,
2442
- e,
2443
- undefined,
2444
- e instanceof NotReadyError ? el._optimisticLane : undefined
2445
- );
2446
- 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
+ }
2447
2567
  } finally {
2448
2568
  tracking = prevTracking;
2449
2569
  latestReadActive = prevLatestRead;
@@ -2505,6 +2625,9 @@ function recompute(el, create = false) {
2505
2625
  }
2506
2626
  } else {
2507
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;
2508
2631
  // Transition-held sync recompute is a write path like setSignal/asyncWrite,
2509
2632
  // so sync derivations of held sources stay visible to isPending()/latest()
2510
2633
  // (#2831). Both companion writes are transition-scoped (optimistic) and
@@ -2526,6 +2649,7 @@ function recompute(el, create = false) {
2526
2649
  // for commit on its own transition's schedule — invisibly (A17/A18).
2527
2650
  if (el._pendingValue === NOT_PENDING) queuePendingNode(el);
2528
2651
  el._pendingValue = value;
2652
+ if (wasLoading) el._loading = true; // see the held branch above (#2990)
2529
2653
  } else if (el._height != oldHeight) {
2530
2654
  for (let s = el._subs; s !== null; s = s._nextSub) {
2531
2655
  insertIntoHeapHeight(s._sub, queueFor(s._sub));
@@ -2582,6 +2706,10 @@ function updateIfNecessary(el) {
2582
2706
  }
2583
2707
  function computed(fn, options) {
2584
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;
2585
2713
  const self = {
2586
2714
  id: inheritId(options, transparent, context),
2587
2715
  _config:
@@ -2598,7 +2726,7 @@ function computed(fn, options) {
2598
2726
  _context: context?._context ?? defaultContext,
2599
2727
  _childCount: 0,
2600
2728
  _fn: fn,
2601
- _value: undefined,
2729
+ _value: loading ? options.loadingValue : undefined,
2602
2730
  _height: 0,
2603
2731
  _child: null,
2604
2732
  _nextHeap: undefined,
@@ -2613,14 +2741,16 @@ function computed(fn, options) {
2613
2741
  _prevSibling: null,
2614
2742
  _firstChild: null,
2615
2743
  _flags: options?.lazy ? REACTIVE_LAZY : REACTIVE_NONE,
2616
- _statusFlags: STATUS_UNINITIALIZED,
2744
+ // A loadingValue node is born committed: commit #0 is already in _value.
2745
+ _statusFlags: loading ? 0 : STATUS_UNINITIALIZED,
2617
2746
  _time: clock,
2618
2747
  _pendingValue: NOT_PENDING,
2619
2748
  _pendingDisposal: null,
2620
2749
  _pendingFirstChild: null,
2621
2750
  _inFlight: null,
2622
2751
  _transition: null,
2623
- _reask: false
2752
+ _reask: false,
2753
+ _loading: loading
2624
2754
  };
2625
2755
  self._name = options?.name ?? "computed";
2626
2756
  setupComputedNode(self, options);
@@ -2671,6 +2801,7 @@ function createEffectNode(fn, effectFn, errorFn, type, notifyStatus, options) {
2671
2801
  _inFlight: null,
2672
2802
  _transition: null,
2673
2803
  _reask: false,
2804
+ _loading: false,
2674
2805
  _modified: false,
2675
2806
  _prevValue: undefined,
2676
2807
  _effectFn: effectFn,
@@ -3698,6 +3829,13 @@ function quietPending(el) {
3698
3829
  }
3699
3830
  return el._reask;
3700
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.
3701
3839
  function newQuestionInFlight(comp) {
3702
3840
  return (
3703
3841
  !!(comp._statusFlags & STATUS_PENDING) &&
@@ -3725,7 +3863,15 @@ function computePendingState(el) {
3725
3863
  (!!(firewall._statusFlags & STATUS_PENDING) && quietPending(firewall))
3726
3864
  );
3727
3865
  }
3728
- 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
+ ) {
3729
3875
  if (hasActiveOverride(el))
3730
3876
  return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._overrideValue));
3731
3877
  return true;
@@ -4409,38 +4555,6 @@ function createSignal(first, second) {
4409
4555
  registerGraph(node, getOwner());
4410
4556
  return [accessor(node), setSignal.bind(null, node)];
4411
4557
  }
4412
- /**
4413
- * Creates a readonly derived reactive memoized signal.
4414
- *
4415
- * ```typescript
4416
- * const value = createMemo<T>(compute, options?: MemoOptions<T>);
4417
- * ```
4418
- * @param compute a function that receives its previous value and returns a new value used to react on a computation
4419
- * @param options `MemoOptions` -- id, name, equals, unobserved, lazy
4420
- *
4421
- * @example
4422
- * ```ts
4423
- * const [first, setFirst] = createSignal("Ada");
4424
- * const [last, setLast] = createSignal("Lovelace");
4425
- *
4426
- * const fullName = createMemo(() => `${first()} ${last()}`);
4427
- *
4428
- * fullName(); // "Ada Lovelace"
4429
- * ```
4430
- *
4431
- * @example
4432
- * ```ts
4433
- * // Async memo — reads surface as pending inside <Loading>
4434
- * const user = createMemo(async () => {
4435
- * const res = await fetch(`/users/${id()}`);
4436
- * return res.json();
4437
- * });
4438
- * ```
4439
- *
4440
- * @description https://docs.solidjs.com/reference/basic-reactivity/create-memo
4441
- */
4442
- // NoInfer keeps the previous-value parameter from influencing T inference, so
4443
- // the memo/effect result type is still driven by the compute return type.
4444
4558
  function createMemo(compute, options) {
4445
4559
  return accessor(computed(compute, options));
4446
4560
  }
@@ -4478,7 +4592,7 @@ function createEffect(compute, effectFn, options) {
4478
4592
  * ```
4479
4593
  * @param compute a function that receives its previous value and returns a new value used to react on a computation
4480
4594
  * @param effectFn a function that receives the new value and is used to perform side effects
4481
- * @param options `EffectOptions` -- name, defer, schedule
4595
+ * @param options `EffectOptions` -- name, defer, schedule, transparent
4482
4596
  *
4483
4597
  * @example
4484
4598
  * ```ts
@@ -5545,13 +5659,17 @@ function createProjectionInternal(fn, seed, options) {
5545
5659
  return wrapped;
5546
5660
  };
5547
5661
  const wrappedStore = wrapProjection(seed);
5548
- node = computed(
5549
- () => {
5550
- if (!node) node = getOwner();
5551
- runProjectionComputed(wrappedStore, fn, options?.key === undefined ? "id" : options.key);
5552
- },
5553
- options?.name ? { name: options.name } : undefined
5554
- );
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);
5555
5673
  node._config &= ~CONFIG_AUTO_DISPOSE;
5556
5674
  return { store: wrappedStore, node };
5557
5675
  }
@@ -5623,19 +5741,48 @@ function runProjectionComputed(wrappedStore, fn, key, wrapCommit, onDraftWrite)
5623
5741
  const owner = getOwner();
5624
5742
  let settled = false;
5625
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;
5626
5761
  const draft = new Proxy(
5627
5762
  wrappedStore,
5628
5763
  createWriteTraps(() => !settled || owner._inFlight === result, onDraftWrite)
5629
5764
  );
5630
5765
  storeSetter(draft, s => {
5631
- result = fn(s);
5766
+ result = fn(shadow ?? s);
5632
5767
  settled = true;
5633
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));
5634
5776
  if (v === s || v === undefined) return;
5635
5777
  const write = () => storeSetter(wrappedStore, s => reconcileState(v, s, key, true));
5636
5778
  wrapCommit ? wrapCommit(write) : write();
5637
5779
  };
5638
- 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);
5639
5786
  });
5640
5787
  return owner;
5641
5788
  }
@@ -7205,23 +7352,24 @@ function createOptimisticProjectionInternal(fn, initialValue, options) {
7205
7352
  setProjectionWriteActive(wasProjectionWriteActive);
7206
7353
  }
7207
7354
  };
7208
- node = computed(
7209
- () => {
7210
- setProjectionWriteActive(true);
7211
- try {
7212
- runProjectionComputed(
7213
- wrappedStore,
7214
- fn,
7215
- options?.key === undefined ? "id" : options.key,
7216
- wrapCommit,
7217
- clearProjectionOverride
7218
- );
7219
- } finally {
7220
- setProjectionWriteActive(false);
7221
- }
7222
- },
7223
- options?.name ? { name: options.name } : undefined
7224
- );
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);
7225
7373
  node._config &= ~CONFIG_AUTO_DISPOSE;
7226
7374
  }
7227
7375
  return { store: wrappedStore, node };