@solidjs/signals 2.0.0-beta.21 → 2.0.0-beta.22

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
@@ -669,15 +669,12 @@ class GlobalQueue extends Queue {
669
669
  // _clearOptimisticStore).
670
670
  static _releaseAffectsScope = null;
671
671
  // affects()-side hooks (wired by affects.ts, mirroring _update): the mark
672
- // engine — count/register/release plus the post-commit re-application of
673
- // marked reads lives with the feature. Every call site is gated by state
674
- // only that module creates, so `!` invocations are safe once the gate holds.
675
- static _applyAffectsReads = null;
672
+ // engine — count/register/release lives with the feature. Every call site
673
+ // is gated by state only that module creates, so `!` invocations are safe
674
+ // once the gate holds.
676
675
  static _releaseAffectsMarks = null;
677
676
  static _markAffects = null;
678
677
  static _releaseAffectsMark = null;
679
- static _onlyMarkPending = null;
680
- static _collectMarkSources = null;
681
678
  // External-source bridge (wired by enableExternalSource(); null while no
682
679
  // config is active — including after _resetExternalSourceConfig()).
683
680
  static _wireExternalSource = null;
@@ -707,11 +704,9 @@ class GlobalQueue extends Queue {
707
704
  // once the gate holds.
708
705
  static _optimisticWrite = null;
709
706
  static _resolveOptimistic = null;
710
- static _stashOptimistic = null;
711
707
  static _transitionBlocked = null;
712
708
  static _cleanupLanes = null;
713
709
  static _runLaneEffects = null;
714
- static _readStashed = null;
715
710
  static _gatedRead = null;
716
711
  static _laneSuspends = null;
717
712
  static _laneReadsCommitted = null;
@@ -750,18 +745,7 @@ class GlobalQueue extends Queue {
750
745
  scheduled = dirtyQueue._max >= dirtyQueue._min || this._batch._pendingNodes.length > 0;
751
746
  reassignPendingTransition(stashedTransition._pendingNodes);
752
747
  activeTransition = null;
753
- // The stash pass (committed-view rerun of plain optimistic signals)
754
- // wraps finalizePureQueue in the engine; a non-empty _optimisticNodes
755
- // means _optimisticWrite ran, which installed the hook.
756
- if (
757
- !stashedTransition._actions.length &&
758
- !stashedTransition._asyncReporters.size &&
759
- stashedTransition._optimisticNodes.length
760
- ) {
761
- GlobalQueue._stashOptimistic(stashedTransition);
762
- } else {
763
- finalizePureQueue(null, true);
764
- }
748
+ finalizePureQueue(null, true);
765
749
  return;
766
750
  }
767
751
  const completingTransition = activeTransition;
@@ -833,6 +817,11 @@ class GlobalQueue extends Queue {
833
817
  if (mask & STATUS_PENDING) {
834
818
  if (flags & STATUS_PENDING) {
835
819
  const actualError = error !== undefined ? error : node._error;
820
+ // A visibility-only mark notification (the affects() boundary
821
+ // channel) updates display state on its way up but must be invisible
822
+ // to completion accounting BY CONSTRUCTION: it never registers a
823
+ // reporter and never counts toward the loading-boundary diagnostic.
824
+ if (actualError?._markVisual) return true;
836
825
  if (activeTransition && actualError) {
837
826
  const source = actualError.source;
838
827
  let reporters = activeTransition._asyncReporters.get(source);
@@ -979,8 +968,14 @@ function finalizePureQueue(completingTransition = null, incomplete = false) {
979
968
  }
980
969
  // Declared motion ends with the transaction: settle (or plain flush end
981
970
  // for ambient marks) releases each registration's refcount. A non-empty
982
- // batch means registerAffectsMark ran, which installed the hook.
983
- if (batch._affectsNodes.length) GlobalQueue._releaseAffectsMarks(batch._affectsNodes);
971
+ // batch means registerAffectsMark ran, which installed the hook. Marks
972
+ // held boundary display state through the visual channel, and their
973
+ // release is the display-state update point — re-run the boundary sweep
974
+ // (the earlier sweep above ran while the marks were still live).
975
+ if (batch._affectsNodes.length) {
976
+ GlobalQueue._releaseAffectsMarks(batch._affectsNodes);
977
+ if (globalQueue._children.length) checkBoundaryChildren(globalQueue);
978
+ }
984
979
  // A non-empty set means trackOptimisticStore ran, which installed the
985
980
  // hook; the hook iterates, clears, and schedules (keeping the loop out of
986
981
  // core lets esbuild shake it — rollup already folds the null guard). The
@@ -1603,9 +1598,14 @@ function unobserved(el) {
1603
1598
  }
1604
1599
  // https://github.com/stackblitz/alien-signals/blob/v2.0.3/src/system.ts#L52
1605
1600
  function link(dep, sub, pendingObserver = false) {
1601
+ // Repeat touches within one pass AND-combine `_pendingObserver`: a probe
1602
+ // read (`isPending(() => x())`) beside a value read of the same dep must
1603
+ // not relabel the value dependency as probe-only — the value read is what
1604
+ // real-error propagation and affects() coverage key off, regardless of
1605
+ // read order within the computation.
1606
1606
  const prevDep = sub._depsTail;
1607
1607
  if (prevDep !== null && prevDep._dep === dep) {
1608
- prevDep._pendingObserver = pendingObserver;
1608
+ prevDep._pendingObserver &&= pendingObserver;
1609
1609
  return;
1610
1610
  }
1611
1611
  let nextDep = null;
@@ -1615,6 +1615,7 @@ function link(dep, sub, pendingObserver = false) {
1615
1615
  if (nextDep !== null && nextDep._dep === dep) {
1616
1616
  nextDep._gen = sub._depGen;
1617
1617
  sub._depsTail = nextDep;
1618
+ // First touch of this pass: the previous pass's label is stale.
1618
1619
  nextDep._pendingObserver = pendingObserver;
1619
1620
  return;
1620
1621
  }
@@ -1630,7 +1631,10 @@ function link(dep, sub, pendingObserver = false) {
1630
1631
  prevSub._sub === sub &&
1631
1632
  (!isRecomputing || prevSub._gen === sub._depGen)
1632
1633
  ) {
1633
- prevSub._pendingObserver = pendingObserver;
1634
+ // Gen-matched during a recompute = repeat touch this pass (AND); outside
1635
+ // a recompute there is no pass boundary, so the latest read labels it.
1636
+ if (isRecomputing) prevSub._pendingObserver &&= pendingObserver;
1637
+ else prevSub._pendingObserver = pendingObserver;
1634
1638
  return;
1635
1639
  }
1636
1640
  const newLink =
@@ -1694,20 +1698,13 @@ function forEachDependent(el, fn) {
1694
1698
  // Queue a node to re-run on the next flush (used both when a pending source
1695
1699
  // settles and when an `isPending` observer must re-evaluate after a real error):
1696
1700
  // shared scheduling helper in heap.ts (tracked effects bypass the heap).
1697
- function settlePendingSource(
1698
- el,
1699
- source = el,
1700
- // Mark release runs inside queue finalization: companion writes must go
1701
- // through the settlement snap (committed), because a setSignal here would
1702
- // open a fresh transition-scoped override window that nothing reverts.
1703
- snap = false
1704
- ) {
1701
+ function settlePendingSource(el) {
1705
1702
  let scheduled = false;
1706
1703
  const visited = new Set();
1707
- // Companion updates no-op without the verdict layer (null hooks).
1708
- const updateCompanions = snap ? GlobalQueue._snapCompanions : GlobalQueue._updatePendingSignal;
1704
+ // Companion updates no-op without the verdict layer (null hook).
1705
+ const updateCompanions = GlobalQueue._updatePendingSignal;
1709
1706
  const settle = node => {
1710
- if (visited.has(node) || !removePendingSource(node, source)) return;
1707
+ if (visited.has(node) || !removePendingSource(node, el)) return;
1711
1708
  visited.add(node);
1712
1709
  node._time = clock;
1713
1710
  const remaining = node._pendingSources?.values().next().value;
@@ -1983,18 +1980,6 @@ function notifyStatus(el, status, error, blockStatus, lane) {
1983
1980
  error = new StatusError(el, error);
1984
1981
  const pendingSource =
1985
1982
  status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined;
1986
- // Mark-sourced propagation must not capture subscribers into the marking
1987
- // action's transaction (#2893): they carry no held value needing a
1988
- // transition-scheduled commit, and stamping `_transition` on them would
1989
- // freeze unrelated writes that share a downstream memo until the action
1990
- // settles. Real async keeps queuing (its commits ride the transition).
1991
- const markSourced = pendingSource?._affectsFor !== undefined;
1992
- // A real error is a settled verdict a mark must not erase (#2893): landing
1993
- // STATUS_PENDING here would clobber `_error` with the sentinel's
1994
- // NotReadyError, and unlike real async there is no arriving value whose
1995
- // recompute would surface the error again. Descent stops too — everything
1996
- // downstream holds the propagated error for the same reason.
1997
- if (markSourced && el._statusFlags & STATUS_ERROR) return;
1998
1983
  const isSource = pendingSource === el;
1999
1984
  const isOptimisticBoundary =
2000
1985
  status === STATUS_PENDING && el._overrideValue !== undefined && !isSource;
@@ -2048,7 +2033,7 @@ function notifyStatus(el, status, error, blockStatus, lane) {
2048
2033
  schedule();
2049
2034
  return;
2050
2035
  }
2051
- if (!downstreamBlockStatus && !markSourced && !sub._transition) queuePendingNode(sub);
2036
+ if (!downstreamBlockStatus && !sub._transition) queuePendingNode(sub);
2052
2037
  notifyStatus(sub, status, error, downstreamBlockStatus, downstreamLane);
2053
2038
  }
2054
2039
  });
@@ -2082,17 +2067,6 @@ let pendingCheckActive = false;
2082
2067
  let latestReadActive = false;
2083
2068
  let context = null;
2084
2069
  let currentOptimisticLane = null;
2085
- /**
2086
- * Marked sources read by the recompute currently on the stack (saved/restored
2087
- * per recompute, like `context`). A computed that reads a node with a live
2088
- * `affects()` mark inherits the mark's pendingness — `recompute` applies the
2089
- * collected sources through `applyAffectsReads` after its commit, because the
2090
- * `clearStatus` at the top of the commit path wipes whatever sentinel entries
2091
- * the node held from the registration-time push. This is the pull half of
2092
- * mark propagation (real async re-establishes itself by re-throwing on read;
2093
- * marks are value-transparent, so they re-establish here instead).
2094
- */
2095
- let affectsReads = null;
2096
2070
  let snapshotCaptureActive = false;
2097
2071
  let snapshotSources = null;
2098
2072
  function ownerInSnapshotScope(owner) {
@@ -2179,9 +2153,6 @@ function recompute(el, create = false) {
2179
2153
  let value = el._pendingValue === NOT_PENDING ? el._value : el._pendingValue;
2180
2154
  let oldHeight = el._height;
2181
2155
  let prevTracking = tracking;
2182
- const prevAffectsReads = affectsReads;
2183
- affectsReads = null;
2184
- let markedReads = null;
2185
2156
  let prevLane = currentOptimisticLane;
2186
2157
  let prevStrictRead = false;
2187
2158
  {
@@ -2189,6 +2160,14 @@ function recompute(el, create = false) {
2189
2160
  strictRead = false;
2190
2161
  }
2191
2162
  tracking = true;
2163
+ // A computed's fn establishes its OWN dependencies, so it must never run
2164
+ // inside a latest() read window: read() short-circuits through the
2165
+ // companion path before dependency linking, so a memo created (eagerly
2166
+ // computed) inside latest(fn) came out permanently dependency-less (#2926).
2167
+ // latestRead() already suspends the flag for its pull-recomputes; this
2168
+ // covers creation-time computes and flushes that run inside the window.
2169
+ const prevLatestRead = latestReadActive;
2170
+ latestReadActive = false;
2192
2171
  // Lane posture lives with the engine: OPTIMISTIC_DIRTY is only ever set by
2193
2172
  // engine-driven paths, and _optimisticNodes is only pushed by
2194
2173
  // _optimisticWrite, so the hook is installed whenever either gate holds.
@@ -2245,12 +2224,11 @@ function recompute(el, create = false) {
2245
2224
  if (reaskChanged) GlobalQueue._repollVerdicts(el);
2246
2225
  } finally {
2247
2226
  tracking = prevTracking;
2227
+ latestReadActive = prevLatestRead;
2248
2228
  strictRead = prevStrictRead;
2249
2229
  if (isStaleEffect) stale = prevStale;
2250
2230
  el._flags = REACTIVE_NONE | (create ? el._flags & REACTIVE_SNAPSHOT_STALE : 0);
2251
2231
  context = oldcontext;
2252
- markedReads = affectsReads;
2253
- affectsReads = prevAffectsReads;
2254
2232
  }
2255
2233
  if (!el._error) {
2256
2234
  trimStaleDeps(el);
@@ -2320,28 +2298,11 @@ function recompute(el, create = false) {
2320
2298
  }
2321
2299
  }
2322
2300
  currentOptimisticLane = prevLane;
2323
- // Marks read during this recompute re-attach after the commit above:
2324
- // earlier, the sentinel's NotReadyError in `_error` would have routed the
2325
- // fresh value into the error-skip branch instead of committing it. A real
2326
- // (non-NotReady) error wins over marks (#2893): applying the sentinel here
2327
- // would clobber the user's error with a NotReadyError from a node whose
2328
- // reads value-transparency promises can never throw NotReady.
2329
- // `markedReads` only collects under a live mark, so the hook is installed.
2330
- if (markedReads && !(el._statusFlags & STATUS_ERROR))
2331
- GlobalQueue._applyAffectsReads(el, markedReads);
2332
- // Mark-only pending doesn't queue (#2893): the node holds no value needing
2333
- // a transition-scheduled commit, and queueing would stamp the marking
2334
- // action's transaction onto it — freezing unrelated writes that share it.
2335
- // (Single mask test up front keeps the mark-free hot path at original cost.)
2336
- const pendFlags = el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED);
2337
2301
  const needsPendingCommit =
2338
2302
  el._pendingValue !== NOT_PENDING ||
2339
2303
  el._pendingFirstChild !== null ||
2340
2304
  el._pendingDisposal !== null ||
2341
- (pendFlags !== 0 &&
2342
- (pendFlags !== STATUS_PENDING ||
2343
- activeAffectsMarks === 0 ||
2344
- !GlobalQueue._onlyMarkPending(el)));
2305
+ (el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) !== 0;
2345
2306
  // Override-covered holds (hasOverride) always queue: their commit belongs
2346
2307
  // to their own transition's schedule (A18 re-rule) and is unobservable
2347
2308
  // under the override (A17). Revert no longer commits anything, so an
@@ -2650,17 +2611,14 @@ function read(el) {
2650
2611
  !snapshotCaptureActive &&
2651
2612
  !strictRead
2652
2613
  ) {
2653
- if (c && tracking) {
2654
- link(el, c);
2655
- // A live mark on a read source flows to the reader (probe reads are
2656
- // excluded: the probe's own collection owns that verdict, and marking
2657
- // the enclosing memo would make an `isPending` wrapper itself pending).
2658
- if (activeAffectsMarks !== 0 && el._affectsCount && !pendingCheckActive)
2659
- (affectsReads ??= []).push(el);
2660
- }
2614
+ if (c && tracking) link(el, c);
2661
2615
  return !c || el._pendingValue === NOT_PENDING ? el._value : el._pendingValue;
2662
2616
  }
2663
- if (strictRead && owner._statusFlags & STATUS_PENDING)
2617
+ // The dev component-body safeguard (#2897) must not fire inside an
2618
+ // isPending() probe: its plain Error would be swallowed by the probe's
2619
+ // catch (which only rethrows NotReadyError), making dev return false where
2620
+ // prod propagates NotReady (#2928). Probe reads follow the prod path.
2621
+ if (strictRead && !pendingCheckActive && owner._statusFlags & STATUS_PENDING)
2664
2622
  throwPendingUntrackedRead(strictRead, {
2665
2623
  ownerId: c?.id,
2666
2624
  ownerName: c?._name,
@@ -2668,16 +2626,6 @@ function read(el) {
2668
2626
  });
2669
2627
  if (c && tracking) {
2670
2628
  link(el, c, pendingCheckActive);
2671
- // Mark inheritance through derivation (see the fast path above), and its
2672
- // transitive half (#2893): a mark-pended owner's sentinel sources flow to
2673
- // the reader like a direct mark — value-transparent reads have no throw
2674
- // to re-establish through, so without this a mid-window recompute sheds
2675
- // pendingness permanently past one derivation level.
2676
- if (activeAffectsMarks !== 0 && !pendingCheckActive) {
2677
- if (el._affectsCount) (affectsReads ??= []).push(el);
2678
- if (owner._statusFlags & STATUS_PENDING)
2679
- GlobalQueue._collectMarkSources(owner, (affectsReads ??= []));
2680
- }
2681
2629
  if (owner._fn) {
2682
2630
  const elQueue = queueFor(el);
2683
2631
  if (owner._height >= elQueue._min) {
@@ -2692,16 +2640,7 @@ function read(el) {
2692
2640
  }
2693
2641
  }
2694
2642
  }
2695
- // Mark-only pending never suspends the reader (#2886): an affects() mark is
2696
- // a promise of change, not an absence of value, so a derived node whose only
2697
- // pending sources are mark sentinels keeps its real value readable —
2698
- // pendingness reaches readers through verdicts (isPending), not throws.
2699
- // (`activeAffectsMarks` gates the source scan off the mark-free hot path;
2700
- // sentinels can't survive in pending sources past their last release.)
2701
- if (
2702
- owner._statusFlags & STATUS_PENDING &&
2703
- !(activeAffectsMarks !== 0 && GlobalQueue._onlyMarkPending(owner))
2704
- ) {
2643
+ if (owner._statusFlags & STATUS_PENDING) {
2705
2644
  if (c && !(stale && owner._transition && activeTransition !== owner._transition)) {
2706
2645
  if (c && c._config & CONFIG_CHILDREN_FORBIDDEN) {
2707
2646
  const message =
@@ -2758,9 +2697,7 @@ function read(el) {
2758
2697
  nodeName: owner?._name
2759
2698
  });
2760
2699
  if (el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING) {
2761
- // An active override means the engine is installed (A17: the override IS
2762
- // the value for every reader — that check itself stays right here).
2763
- if (c && stale && GlobalQueue._readStashed(el)) return el._value;
2700
+ // A17: the override IS the value for every reader.
2764
2701
  return unwrapOverride(el._overrideValue);
2765
2702
  }
2766
2703
  // Entanglement gate: a reader recomputing under an optimistic lane that reads
@@ -3151,9 +3088,6 @@ function isUndefined(value) {
3151
3088
  * `_optimisticNodes.length`, `activeLanes.size`), so `!` invocations are safe
3152
3089
  * once the gate holds — the same late-binding contract as verdict.ts.
3153
3090
  */
3154
- // When a background transition is stashed, plain optimistic signals need one
3155
- // committed-view rerun. Keep that override local to the stash flush.
3156
- let stashedOptimisticReads = null;
3157
3091
  /** The optimistic half of setSignal, fired when `_overrideValue !== undefined`. */
3158
3092
  function optimisticWrite(el, v) {
3159
3093
  const hasOverride = el._overrideValue !== NOT_PENDING;
@@ -3191,34 +3125,6 @@ function optimisticWrite(el, v) {
3191
3125
  schedule();
3192
3126
  return v;
3193
3127
  }
3194
- function readStashed(node) {
3195
- return !!stashedOptimisticReads?.has(node);
3196
- }
3197
- function queueStashedOptimisticEffects(node) {
3198
- for (let s = node._subs; s !== null; s = s._nextSub) {
3199
- const sub = s._sub;
3200
- if (!sub._type) continue;
3201
- enqueueSub(sub);
3202
- }
3203
- }
3204
- /**
3205
- * Incomplete-transition finalization when the stashed transition holds
3206
- * optimistic nodes: give plain optimistic signals one committed-view rerun.
3207
- */
3208
- function stashOptimistic(stashedTransition) {
3209
- stashedOptimisticReads = new Set();
3210
- for (let i = 0; i < stashedTransition._optimisticNodes.length; i++) {
3211
- const node = stashedTransition._optimisticNodes[i];
3212
- if (node._fn || node._config & CONFIG_OWNED_WRITE) continue;
3213
- stashedOptimisticReads.add(node);
3214
- queueStashedOptimisticEffects(node);
3215
- }
3216
- try {
3217
- finalizePureQueue(null, true);
3218
- } finally {
3219
- stashedOptimisticReads = null;
3220
- }
3221
- }
3222
3128
  /**
3223
3129
  * transitionComplete's override blockage: a settling transition stays open
3224
3130
  * while one of its optimistic nodes holds an active override that is still
@@ -3231,11 +3137,7 @@ function transitionBlocked(transition) {
3231
3137
  hasActiveOverride(node) &&
3232
3138
  "_statusFlags" in node &&
3233
3139
  node._statusFlags & STATUS_PENDING &&
3234
- node._error instanceof NotReadyError &&
3235
- // Mark-sourced pending never blocks settlement: affects() releases AT
3236
- // settle, so counting its sentinel here would deadlock the window it
3237
- // is scoped to.
3238
- !node._error.source?._affectsFor
3140
+ node._error instanceof NotReadyError
3239
3141
  ) {
3240
3142
  return true;
3241
3143
  }
@@ -3397,11 +3299,9 @@ function installOptimisticEngine() {
3397
3299
  if (GlobalQueue._optimisticWrite !== null) return;
3398
3300
  GlobalQueue._optimisticWrite = optimisticWrite;
3399
3301
  GlobalQueue._resolveOptimistic = resolveOptimisticNodes;
3400
- GlobalQueue._stashOptimistic = stashOptimistic;
3401
3302
  GlobalQueue._transitionBlocked = transitionBlocked;
3402
3303
  GlobalQueue._cleanupLanes = cleanupCompletedLanes;
3403
3304
  GlobalQueue._runLaneEffects = runLaneEffects;
3404
- GlobalQueue._readStashed = readStashed;
3405
3305
  GlobalQueue._gatedRead = gatedRead;
3406
3306
  GlobalQueue._laneSuspends = laneSuspends;
3407
3307
  GlobalQueue._laneReadsCommitted = laneReadsCommitted;
@@ -3450,6 +3350,46 @@ function collectPendingSources(el) {
3450
3350
  function witnessAffects(node) {
3451
3351
  pendingProbe?.sources.add(node);
3452
3352
  }
3353
+ /**
3354
+ * The affects() coverage walk — the read half of the dedicated mark channel.
3355
+ * A node is covered by a live mark iff it carries one (`_affectsCount`) or
3356
+ * derives, through its CURRENT deps (hopping store firewalls), from a node
3357
+ * that does. Pull-based coverage means graph rewires, mid-window recomputes,
3358
+ * and probe-triggered recomputes can never strand or strip a mark — there is
3359
+ * nothing stored downstream to corrupt. Probe-created links
3360
+ * (`_pendingObserver`) are skipped so an `isPending` wrapper memo never
3361
+ * inherits the coverage it reports on.
3362
+ */
3363
+ function markWalk(el, seen) {
3364
+ if (el._affectsCount) return true;
3365
+ // A real error outranks an inherited mark (A16/A24c): an errored node
3366
+ // answers probes with its error, not a coverage verdict, and coverage does
3367
+ // not flow through it — matching the rails' behavior, where propagation
3368
+ // stopped at errored nodes. A DIRECT mark on an errored node still reads
3369
+ // pending (the count check above), also matching.
3370
+ if (el._statusFlags & STATUS_ERROR) return false;
3371
+ if (seen.has(el)) return false;
3372
+ seen.add(el);
3373
+ const firewall = el._firewall;
3374
+ if (firewall && markWalk(firewall, seen)) return true;
3375
+ // Mid-recompute (the clearStatus companion poke runs before
3376
+ // trimStaleDeps), only the validated prefix [_deps.._depsTail] is this
3377
+ // pass's dependency set — walking past it would read dropped deps and
3378
+ // latch a stale verdict on the companion.
3379
+ const comp = el;
3380
+ const tail = comp._flags & REACTIVE_RECOMPUTING_DEPS ? comp._depsTail : undefined;
3381
+ if (tail !== null) {
3382
+ for (let d = comp._deps ?? null; d !== null; d = d._nextDep) {
3383
+ if (!d._pendingObserver && markWalk(d._dep, seen)) return true;
3384
+ if (d === tail) break;
3385
+ }
3386
+ }
3387
+ return false;
3388
+ }
3389
+ /** Gated entry: apps with no live mark pay one integer compare. */
3390
+ function markCovered(el) {
3391
+ return activeAffectsMarks !== 0 && markWalk(el, new Set());
3392
+ }
3453
3393
  function quietPending(el) {
3454
3394
  if (el._pendingSources) {
3455
3395
  for (const source of el._pendingSources) if (!source._reask) return false;
@@ -3467,11 +3407,13 @@ function newQuestionInFlight(comp) {
3467
3407
  function computePendingState(el) {
3468
3408
  const comp = el;
3469
3409
  if (comp._flags & REACTIVE_DISPOSED) return false;
3470
- if (el._affectsCount) return true;
3410
+ // Mark coverage is transitive by dep-graph reachability: a latest() shadow
3411
+ // reaches its owner (and a store leaf its firewall) through its own deps,
3412
+ // so the one walk covers direct marks, derivation, and companion chains.
3413
+ if (markCovered(el)) return true;
3471
3414
  const firewall = el._firewall;
3472
3415
  if (el._parentSource) {
3473
3416
  const parentNode = el._parentSource;
3474
- if (parentNode._affectsCount) return true;
3475
3417
  const parent = parentNode._firewall || parentNode;
3476
3418
  return newQuestionInFlight(parent);
3477
3419
  }
@@ -3504,12 +3446,22 @@ function updateChildCompanions(el) {
3504
3446
  if (child._pendingSignal || child._latestValueComputed) updatePendingSignal(child);
3505
3447
  }
3506
3448
  }
3507
- function repollDownstreamVerdicts(el) {
3449
+ /**
3450
+ * Re-derive every verdict companion downstream of `el` (subs + firewall
3451
+ * children, dedup'd). The affects() channel's poke walk: registration and
3452
+ * re-ask flips use the live write path (companion setSignal — its own lane
3453
+ * lets the wake escape an incomplete transition's effect stash, #2887);
3454
+ * mark release passes `snap` because it runs inside queue finalization,
3455
+ * where companion writes must land committed (a setSignal there would open
3456
+ * a fresh override window that nothing settles).
3457
+ */
3458
+ function repollDownstreamVerdicts(el, snap = false) {
3459
+ const update = snap ? snapCompanionsToState : updatePendingSignal;
3508
3460
  const visited = new Set();
3509
3461
  const visit = node => {
3510
3462
  if (visited.has(node)) return;
3511
3463
  visited.add(node);
3512
- if (node._pendingSignal || node._latestValueComputed) updatePendingSignal(node);
3464
+ if (node._pendingSignal || node._latestValueComputed) update(node);
3513
3465
  for (let s = node._subs; s !== null; s = s._nextSub) visit(s._sub);
3514
3466
  for (let child = node._child ?? null; child !== null; child = child._nextChild) {
3515
3467
  visit(child);
@@ -4336,6 +4288,12 @@ function createReaction(effectFn, options) {
4336
4288
  });
4337
4289
  };
4338
4290
  }
4291
+ /** Delivers effect applies on a microtask instead of queueing them (#2930). */
4292
+ class MicrotaskQueue extends Queue {
4293
+ enqueue(type, fn) {
4294
+ queueMicrotask(() => fn(type));
4295
+ }
4296
+ }
4339
4297
  /**
4340
4298
  * Awaits a reactive expression and returns its first fully-settled value as a
4341
4299
  * `Promise`. Pending async reads (`createMemo` returning a promise, etc.) are
@@ -4365,6 +4323,17 @@ function resolve(fn) {
4365
4323
  }
4366
4324
  return new Promise((res, rej) => {
4367
4325
  createRoot(dispose => {
4326
+ // Deliver effect applies on a microtask instead of the owner queue: an
4327
+ // incomplete transition stashes its effect queues until it settles, but
4328
+ // an action yielding this promise is itself what keeps the transition
4329
+ // open — the stashed res() deadlocked the action (#2930). The compute
4330
+ // still runs in place (under the transaction's view when created inside
4331
+ // an action step), and status/boundary notifications keep their normal
4332
+ // route through the inherited queue.
4333
+ const owner = getOwner();
4334
+ const queue = new MicrotaskQueue();
4335
+ queue._parent = owner._queue; // notify() forwards up the normal chain
4336
+ owner._queue = queue;
4368
4337
  // A user effect rather than a bare computed: computeds are pull-based and
4369
4338
  // are only re-enqueued when a pending source *resolves* — a rejection just
4370
4339
  // marks them errored, so nothing would re-run and the promise would never
@@ -6103,127 +6072,76 @@ function createStore(first, second, options) {
6103
6072
  }
6104
6073
 
6105
6074
  /**
6106
- * The pending-source identity of a live `affects()` mark on `node` (lazy,
6107
- * one per node, shared by overlapping registrations via the refcount).
6108
- *
6109
- * A mark rides the SAME status rails as real in-flight async — downstream
6110
- * subscribers hold the sentinel in `_pendingSources` but under its own
6111
- * identity so the two channels can't clear each other:
6112
- * - `_reask` is permanently `false`: a mark is by definition a declared
6113
- * value change, so `quietPending` never silences a window it participates
6114
- * in — even when the mark rides over an otherwise-quiet `refresh()`
6115
- * re-ask of the same node (the whole point of declaring one).
6116
- * - A landing on the marked node settles only the node's OWN source entry;
6117
- * the sentinel entry survives until the mark's transaction releases it.
6118
- * - The sentinel itself never carries `STATUS_PENDING`, so
6119
- * `transitionComplete` never counts a mark as a blocker of its own
6120
- * transaction (release happens AT settle — self-blocking would deadlock),
6121
- * and reads of the marked node never throw (marks are value-transparent
6122
- * at the source; pendingness is what propagates).
6075
+ * The counting half of a mark, shared by direct registration and store-scope
6076
+ * inheritance (a node created inside a live keyless mark's identity scope).
6077
+ * A mark is ONLY this count: coverage of everything derived from the node is
6078
+ * pull-derived by the verdict layer's `markWalk` (dep-graph reachability), so
6079
+ * nothing is stored downstream and nothing can be stranded or stripped by
6080
+ * mid-window recomputes.
6123
6081
  */
6124
- function getAffectsSentinel(node) {
6125
- return (node._affectsSentinel ??= {
6126
- _name: "affects-sentinel",
6127
- // Brand + backref: lets the scheduler's settlement checks recognize
6128
- // mark-sourced pending (which must never block its own transaction —
6129
- // release happens AT settle).
6130
- _affectsFor: node,
6131
- _flags: 0,
6132
- _statusFlags: 0,
6133
- _reask: false,
6134
- _error: undefined,
6135
- _subs: null,
6136
- _deps: null
6137
- });
6082
+ function markAffects(node) {
6083
+ node._affectsCount = (node._affectsCount || 0) + 1;
6084
+ shiftAffectsMarks(1);
6138
6085
  }
6139
6086
  /**
6140
- * Push a live mark's pendingness downstream from the marked node through the
6141
- * normal status rails. Runs on every registration (dedup in `notifyStatus`
6142
- * stops re-descent at already-covered subscribers). Subscribers that
6143
- * recompute mid-window shed this via `clearStatus` and re-acquire it through
6144
- * the read path (`applyAffectsReads` for direct readers of the marked node,
6145
- * `collectMarkSources` transitively) the mark analogue of real async's
6146
- * re-throw-on-read. Subscribers are deliberately NOT queued as pending nodes
6147
- * (#2893): they hold no value needing a transition-scheduled commit, and
6148
- * queueing would stamp the marking action's transaction onto them from then
6149
- * on ANY write dirtying one of them (including writes to unmarked signals
6150
- * that merely share a downstream memo) would be captured and frozen until the
6151
- * action settles.
6087
+ * Boundary visual channel: within a transaction, a live mark holds Loading
6088
+ * fallbacks / reveal ordering the way real in-flight async would — but the
6089
+ * notification is tagged visibility-only at the source (`_markVisual`), so
6090
+ * the root queue never registers reporters from it: marks are invisible to
6091
+ * ALL completion and settlement accounting by construction. Boundaries hold
6092
+ * the marked node in `_sources` while `_affectsCount` is live; the release
6093
+ * sweep (finalizePureQueue re-checks boundary children after mark release)
6094
+ * is the display-state update point. Ambient marks release inside the same
6095
+ * flush that would surface them, before effects runverdict-only, netting
6096
+ * no visual change.
6152
6097
  */
6153
- function propagateAffectsMark(node) {
6098
+ function notifyMarkBoundaries(node) {
6154
6099
  if (!node._subs && !node._child) return;
6155
- const sentinel = getAffectsSentinel(node);
6156
- const error = new NotReadyError(sentinel);
6157
- forEachDependent(node, sub => {
6158
- if (!sub._pendingSources?.has(sentinel)) {
6159
- notifyStatus(sub, STATUS_PENDING, error);
6160
- }
6161
- });
6162
- }
6163
- /**
6164
- * Re-establish mark pendingness on a computed that read marked sources
6165
- * during its recompute (`clearStatus` at the top of the commit path wiped
6166
- * any sentinel entries it held). Called by `recompute` after the commit —
6167
- * not before, because setting `_error` earlier would make the commit path
6168
- * treat the node as errored and skip the value write.
6169
- */
6170
- function applyAffectsReads(el, sources) {
6171
- let applied = false;
6172
- for (let i = 0; i < sources.length; i++) {
6173
- const src = sources[i];
6174
- if (!src._affectsCount) continue;
6175
- const sentinel = getAffectsSentinel(src);
6176
- if (addPendingSource(el, sentinel)) {
6177
- el._statusFlags |= STATUS_PENDING;
6178
- setPendingError(el, sentinel);
6179
- applied = true;
6100
+ const error = new NotReadyError(node);
6101
+ error._markVisual = true;
6102
+ const visited = new Set();
6103
+ const visit = sub => {
6104
+ if (visited.has(sub)) return;
6105
+ visited.add(sub);
6106
+ // Display consumers (render effects, boundary computeds) act on the
6107
+ // notification; descent stops there, exactly like the status rails.
6108
+ if (sub._notifyStatus) {
6109
+ sub._notifyStatus(STATUS_PENDING, error);
6110
+ return;
6180
6111
  }
6181
- }
6182
- if (applied && GlobalQueue._updatePendingSignal !== null) GlobalQueue._updatePendingSignal(el);
6183
- }
6184
- /**
6185
- * The counting half of a mark, shared by direct registration and store-scope
6186
- * inheritance (a node created inside a live keyless mark's identity scope):
6187
- * bumps the refcount and pokes the node's verdict companions so an
6188
- * already-materialized `false` flips reactively.
6189
- */
6190
- function markAffects(node) {
6191
- node._affectsCount = (node._affectsCount || 0) + 1;
6192
- shiftAffectsMarks(1);
6193
- // Companions only exist once the verdict layer (isPending/latest) loaded;
6194
- // without them there is no materialized verdict to flip.
6195
- if (node._affectsCount === 1 && GlobalQueue._updatePendingSignal !== null)
6196
- GlobalQueue._updatePendingSignal(node);
6112
+ forEachDependent(sub, visit);
6113
+ };
6114
+ forEachDependent(node, visit);
6197
6115
  }
6198
6116
  /**
6199
6117
  * Registers one `affects()` mark on a node: counts it, records the
6200
6118
  * registration with the current transaction (after initTransition the queue's
6201
- * batch IS the active transition, mirroring `_optimisticNodes`), and
6202
- * propagates STATUS_PENDING downstream on the status rails so everything
6203
- * DERIVED from the marked data reads pending too. Propagation runs on every
6204
- * registration (not just the first): subscribers gained since an earlier
6205
- * overlapping registration get covered, and dedup stops re-descent early.
6119
+ * batch IS the active transition, mirroring `_optimisticNodes`), re-derives
6120
+ * every downstream verdict companion (the mark channel's only push — verdict
6121
+ * pokes, not state), and notifies boundary display state. Both walks run on
6122
+ * every registration (not just the first): subscribers gained since an
6123
+ * earlier overlapping registration get covered, and dedup stops re-descent.
6206
6124
  */
6207
6125
  function registerAffectsMark(node) {
6208
6126
  markAffects(node);
6209
6127
  globalQueue._batch._affectsNodes.push(node);
6210
- propagateAffectsMark(node);
6128
+ // Companions only exist once the verdict layer (isPending/latest) loaded;
6129
+ // without them there is no materialized verdict to poke.
6130
+ GlobalQueue._repollVerdicts !== null && GlobalQueue._repollVerdicts(node);
6131
+ notifyMarkBoundaries(node);
6211
6132
  schedule();
6212
6133
  }
6213
6134
  /**
6214
- * Releases one registration. When the node's last mark drops, settles the
6215
- * mark's sentinel out of every downstream `_pendingSources` (waking blocked
6216
- * nodes and re-deriving verdicts along the walk). Companion writes go through
6217
- * the settlement snap (committed, not transition-scoped) so releasing a mark
6218
- * can't open a fresh override window that would itself need settlement.
6135
+ * Releases one registration. When the node's last mark drops, re-derives
6136
+ * every downstream verdict through the settlement snap (committed, not
6137
+ * transition-scoped release runs inside queue finalization, where a
6138
+ * setSignal would open a fresh override window that nothing settles).
6219
6139
  */
6220
6140
  function releaseAffectsMark(node) {
6221
6141
  shiftAffectsMarks(-1);
6222
6142
  node._affectsCount--;
6223
6143
  if (!node._affectsCount) {
6224
- const sentinel = node._affectsSentinel;
6225
- if (sentinel) settlePendingSource(node, sentinel, true);
6226
- GlobalQueue._snapCompanions !== null && GlobalQueue._snapCompanions(node);
6144
+ GlobalQueue._repollVerdicts !== null && GlobalQueue._repollVerdicts(node, true);
6227
6145
  GlobalQueue._releaseAffectsScope?.(node);
6228
6146
  }
6229
6147
  }
@@ -6235,53 +6153,14 @@ function releaseAffectsMarks(nodes) {
6235
6153
  for (let i = 0; i < nodes.length; i++) releaseAffectsMark(nodes[i]);
6236
6154
  nodes.length = 0;
6237
6155
  }
6238
- /**
6239
- * True when a node's pending status comes ONLY from affects() sentinels. A
6240
- * mark is a promise of change, not an absence of value: reads of mark-pended
6241
- * derived nodes stay value-transparent (verdicts report pending; the read
6242
- * path must not suspend). Any real async source among the pending sources
6243
- * keeps normal suspension semantics. Core's read path reaches this through
6244
- * `GlobalQueue._onlyMarkPending`, gated on the `activeAffectsMarks` counter.
6245
- */
6246
- function onlyMarkPending(el) {
6247
- const sources = el._pendingSources;
6248
- if (sources) {
6249
- for (const s of sources) if (!s._affectsFor) return false;
6250
- return true;
6251
- }
6252
- return false;
6253
- }
6254
- /**
6255
- * Collect the still-live marked nodes behind a pended owner's sentinel
6256
- * sources into a recompute's `affectsReads`. This is the transitive half of
6257
- * read-path re-establishment (#2893): real async re-establishes at every
6258
- * derivation level because its re-throw on read re-registers the source, but
6259
- * mark-pended reads are value-transparent — without this, pendingness dies on
6260
- * the first mid-window recompute past depth one, and the isPending() probe
6261
- * itself (whose prepare step recomputes retryable NotReady holders) strips
6262
- * the very status it reports on. Reached through
6263
- * `GlobalQueue._collectMarkSources`, gated on `activeAffectsMarks`.
6264
- */
6265
- function collectMarkSources(el, into) {
6266
- if (el._pendingSources) {
6267
- for (const s of el._pendingSources) {
6268
- const marked = s._affectsFor;
6269
- if (marked && marked._affectsCount) into.push(marked);
6270
- }
6271
- }
6272
- }
6273
6156
  // Late installation (same pattern as `GlobalQueue._update`): the mark engine
6274
6157
  // lives with the feature so graphs that never declare a mark never ship it.
6275
- // Each call site is gated by state only this module creates (`markedReads`
6276
- // collection under `activeAffectsMarks`, a non-empty `_affectsNodes` batch,
6277
- // a live scope in the store's `affectsScopes`), so the hooks are installed
6278
- // before the first time any of them can fire.
6279
- GlobalQueue._applyAffectsReads = applyAffectsReads;
6158
+ // Each call site is gated by state only this module creates (a non-empty
6159
+ // `_affectsNodes` batch, a live scope in the store's `affectsScopes`), so the
6160
+ // hooks are installed before the first time any of them can fire.
6280
6161
  GlobalQueue._releaseAffectsMarks = releaseAffectsMarks;
6281
6162
  GlobalQueue._markAffects = markAffects;
6282
6163
  GlobalQueue._releaseAffectsMark = releaseAffectsMark;
6283
- GlobalQueue._onlyMarkPending = onlyMarkPending;
6284
- GlobalQueue._collectMarkSources = collectMarkSources;
6285
6164
  function affects(target, key) {
6286
6165
  if (arguments.length > 2) {
6287
6166
  const message =
@@ -7512,9 +7391,14 @@ class CollectionQueue extends Queue {
7512
7391
  }
7513
7392
  _checkSources() {
7514
7393
  for (const source of this._sources) {
7394
+ // A source with a live affects() mark holds display state for the
7395
+ // mark's lifetime (the visual channel): the marked node carries no
7396
+ // status of its own, so the count is the liveness test. The release
7397
+ // sweep (finalizePureQueue after mark release) re-runs this check.
7515
7398
  if (
7516
7399
  source._flags & REACTIVE_DISPOSED ||
7517
- (!(source._statusFlags & this._collectionType) &&
7400
+ (!source._affectsCount &&
7401
+ !(source._statusFlags & this._collectionType) &&
7518
7402
  !(this._collectionType & STATUS_ERROR && source._statusFlags & STATUS_PENDING))
7519
7403
  )
7520
7404
  this._sources.delete(source);