@solidjs/signals 2.0.0-rc.5 → 2.0.0-rc.6

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
@@ -232,7 +232,8 @@ const defaultOptions = {
232
232
  wideDeps: 30,
233
233
  hotTime: { budgetMs: 8, windowMs: 1000 },
234
234
  unstableMemos: 4,
235
- wideWrites: 250
235
+ wideWrites: 250,
236
+ waterfalls: { minFlightMs: 50 }
236
237
  };
237
238
  let options = { ...defaultOptions };
238
239
  const listeners = new Set();
@@ -425,10 +426,13 @@ function checkDepWidth(el) {
425
426
  });
426
427
  console.warn(message);
427
428
  }
429
+ const hotCauses = new Map();
430
+ const HOT_FANOUT_FIRST_MILESTONE = 5;
428
431
  /**
429
432
  * Hot-scope warning — flags a scope that re-ran more than `count` times
430
433
  * inside one `windowMs` window. Warned once per window, with the most recent
431
434
  * cause chain named so the leaking signal is identified in the message.
435
+ * Fan-out spam is folded per root cause (see HotCauseWindow above).
432
436
  */
433
437
  function checkHotRuns(el, event) {
434
438
  const cfg = options.hotRuns;
@@ -443,22 +447,55 @@ function checkHotRuns(el, event) {
443
447
  node._devWinCount = (node._devWinCount ?? 0) + 1;
444
448
  if (node._devHotWarned || node._devWinCount < cfg.count) return;
445
449
  node._devHotWarned = true;
446
- const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
450
+ // Root-cause key: the set of originating writes behind this scope's latest
451
+ // re-run. Scopes hot from the SAME roots share one aggregation window.
452
+ const roots = new Set();
453
+ rootsOf(event.causes, roots);
454
+ const causeKey = roots.size > 0 ? [...roots].sort().join(", ") : "(untracked)";
455
+ let window = hotCauses.get(causeKey);
456
+ if (window === undefined || now - window.winStart > cfg.windowMs) {
457
+ window = { winStart: now, scopes: 0, runs: 0, nextMilestone: HOT_FANOUT_FIRST_MILESTONE };
458
+ hotCauses.set(causeKey, window);
459
+ }
460
+ window.scopes++;
461
+ window.runs += node._devWinCount;
462
+ if (window.scopes === 1) {
463
+ const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
464
+ const message =
465
+ `[HOT_SCOPE_RERUNS] ${event.nodeKind} "${event.nodeName}" re-ran ${node._devWinCount} times ` +
466
+ `in ${Math.max(1, now - node._devWinStart)}ms — a hot signal is likely leaking into this ` +
467
+ `scope. Latest cause: ${rootCause || "(untracked pull)"}`;
468
+ emitDiagnostic({
469
+ code: "HOT_SCOPE_RERUNS",
470
+ kind: "perf",
471
+ severity: "warn",
472
+ message,
473
+ nodeName: event.nodeName,
474
+ data: {
475
+ runs: node._devWinCount,
476
+ windowMs: cfg.windowMs,
477
+ causes: event.causes.map(c => c.name)
478
+ }
479
+ });
480
+ console.warn(message);
481
+ return;
482
+ }
483
+ // Additional scopes hot from the same cause: silent until a milestone —
484
+ // the culprit is the cause, and it has already been named once.
485
+ if (window.scopes < window.nextMilestone) return;
486
+ window.nextMilestone *= 10;
447
487
  const message =
448
- `[HOT_SCOPE_RERUNS] ${event.nodeKind} "${event.nodeName}" re-ran ${node._devWinCount} times ` +
449
- `in ${Math.max(1, now - node._devWinStart)}msa hot signal is likely leaking into this ` +
450
- `scope. Latest cause: ${rootCause || "(untracked pull)"}`;
488
+ `[HOT_SCOPE_FANOUT] ${window.scopes} scopes have gone hot (${window.runs} re-runs) within ` +
489
+ `${cfg.windowMs}ms, all driven by ${causeKey} — one hot cause is re-running a large part ` +
490
+ `of the graph. Per-scope warnings are suppressed; fix the cause. If consumers ask keyed ` +
491
+ `questions of it, invert with createSelector or createProjection.`;
451
492
  emitDiagnostic({
452
- code: "HOT_SCOPE_RERUNS",
493
+ code: "HOT_SCOPE_FANOUT",
453
494
  kind: "perf",
454
495
  severity: "warn",
455
496
  message,
456
- nodeName: event.nodeName,
457
- data: {
458
- runs: node._devWinCount,
459
- windowMs: cfg.windowMs,
460
- causes: event.causes.map(c => c.name)
461
- }
497
+ nodeName: causeKey,
498
+ data: { cause: causeKey, scopes: window.scopes, runs: window.runs, windowMs: cfg.windowMs }
462
499
  });
463
500
  console.warn(message);
464
501
  }
@@ -637,6 +674,120 @@ function checkUnstableOutput(el, prevValue, newValue) {
637
674
  });
638
675
  console.warn(message);
639
676
  }
677
+ // WeakMaps: an errored/abandoned flight must not leak its node or block GC.
678
+ const liveFlights = new WeakMap();
679
+ const landedFlights = new WeakMap();
680
+ /**
681
+ * Flight-object identity → earliest known start. Fed by markFlight() (the
682
+ * cooperative preload/cache declaration — populated even while attribution
683
+ * is disabled, so navigation-time marks survive a later enable()) and by
684
+ * first sightings at registration.
685
+ */
686
+ const flightOrigins = new WeakMap();
687
+ let waterfallLog = [];
688
+ /** Deepest landed-flight cause reachable through a cause list (derived links included). */
689
+ function flightCauseIn(causes) {
690
+ let best = null;
691
+ for (const c of causes) {
692
+ let found = null;
693
+ if (c.kind === "async") found = landedFlights.get(c) ?? null;
694
+ else if (c.kind === "derived" && c.causes) found = flightCauseIn(c.causes);
695
+ if (found !== null && (best === null || found.chain.length > best.chain.length)) best = found;
696
+ }
697
+ return best;
698
+ }
699
+ function trackFlightStart(el, flight) {
700
+ if (options.waterfalls === false) return;
701
+ const at = now();
702
+ const origin = flightOrigins.get(flight) ?? at;
703
+ if (origin === at) flightOrigins.set(flight, at);
704
+ // Nearest enclosing frame with causes: create runs carry null (a node born
705
+ // inside a parent's recompute inherits the parent's causality — the
706
+ // boundary-reveal case, and the lazy sibling whose first pull is gated
707
+ // behind an earlier not-ready read), so walk down to the first re-run frame.
708
+ let parent = null;
709
+ for (let i = frames.length - 1; i >= 0; i--) {
710
+ const causes = frames[i].causes;
711
+ if (causes !== null) {
712
+ parent = flightCauseIn(causes);
713
+ break;
714
+ }
715
+ }
716
+ // The sequentiality test. A marked/previously-seen flight whose origin
717
+ // predates the upstream landing was in the air alongside it: parallel.
718
+ if (parent !== null && origin < parent.landedAt) parent = null;
719
+ liveFlights.set(el, {
720
+ origin,
721
+ startSeq: changeSeq,
722
+ chain: parent === null ? [] : [...parent.chain, { name: parent.name, ms: parent.ms }]
723
+ });
724
+ }
725
+ /**
726
+ * Flight landed (whether or not the value committed — the wall time was
727
+ * spent either way). Attach the measurement to the landing's fresh "async"
728
+ * stamp so downstream flights can chain through it, then judge the chain.
729
+ */
730
+ function finalizeFlight(el) {
731
+ const flight = liveFlights.get(el);
732
+ if (flight === undefined) return;
733
+ liveFlights.delete(el);
734
+ const landedAt = now();
735
+ const ms = landedAt - flight.origin;
736
+ const record = el._devChange;
737
+ // Only a stamp this landing produced may carry the measurement — a stale
738
+ // async record from a previous landing must not be re-labeled.
739
+ if (record !== undefined && record.kind === "async" && record.seq > flight.startSeq)
740
+ landedFlights.set(record, { name: nodeName(el), ms, chain: flight.chain, landedAt });
741
+ checkWaterfall(el, flight.chain, ms);
742
+ }
743
+ function checkWaterfall(el, chain, ms) {
744
+ const cfg = options.waterfalls;
745
+ if (cfg === false) return;
746
+ if (chain.length > 0) {
747
+ waterfallLog.push({
748
+ chain: [...chain, { name: nodeName(el), ms }],
749
+ sequentialMs: chain.reduce((sum, l) => sum + l.ms, ms)
750
+ });
751
+ if (waterfallLog.length > options.historyLimit) waterfallLog.shift();
752
+ }
753
+ // The verdict: trailing run of links that were each a real wait. A fast
754
+ // tail (settled preload/cache hit) or a fast upstream breaks the sequence.
755
+ if (ms < cfg.minFlightMs) return;
756
+ let seq = 1;
757
+ let totalMs = ms;
758
+ for (let i = chain.length - 1; i >= 0 && chain[i].ms >= cfg.minFlightMs; i--) {
759
+ seq++;
760
+ totalMs += chain[i].ms;
761
+ }
762
+ if (seq < 2) return;
763
+ const node = el;
764
+ if ((node._devWaterfallWarnedAt ?? 0) >= seq) return;
765
+ node._devWaterfallWarnedAt = seq;
766
+ const links = [...chain.slice(chain.length - (seq - 1)), { name: nodeName(el), ms }];
767
+ const path = links.map(l => `"${l.name}" (${l.ms.toFixed(0)}ms)`).join(" → ");
768
+ const message =
769
+ `[ASYNC_WATERFALL] ${seq} sequential async flights — ${path} — ` +
770
+ `${totalMs.toFixed(0)}ms serialized: each began only after the previous resolved ` +
771
+ `(as far as this graph can see). If a later request doesn't need the earlier ` +
772
+ `response, derive both from the same inputs so they start together; if the ` +
773
+ `dependency is intrinsic, preload the dependent data or join the requests ` +
774
+ `server-side. If this work WAS already started elsewhere (a preloader or request ` +
775
+ `cache), have that layer stamp its promises with DEV.attribution.markFlight().`;
776
+ // Depth 2 is advisory-only (structured consumers see it; the console does
777
+ // not): a 2-chain can be an intrinsic data dependency or an unmarked
778
+ // preload. A 3+ chain that survived the origin test is near-certainly
779
+ // structural — that one earns the console.
780
+ const severity = seq > 2 ? "warn" : "info";
781
+ emitDiagnostic({
782
+ code: "ASYNC_WATERFALL",
783
+ kind: "perf",
784
+ severity,
785
+ message,
786
+ nodeName: nodeName(el),
787
+ data: { chain: links.map(l => ({ name: l.name, ms: l.ms })), sequentialMs: totalMs }
788
+ });
789
+ if (severity === "warn") console.warn(message);
790
+ }
640
791
  // The engine's implementation of the core's dev hook points. Installed by
641
792
  // enable(), uninstalled by disable() — while uninstalled the core pays one
642
793
  // null check per site and nothing else.
@@ -710,6 +861,9 @@ const engineHooks = {
710
861
  refreshed(el) {
711
862
  stampWrite(el, "refresh");
712
863
  },
864
+ flightStart(el, flight) {
865
+ trackFlightStart(el, flight);
866
+ },
713
867
  asyncStart(el) {
714
868
  asyncStartSeq = el._devChange?.seq ?? 0;
715
869
  asyncStartTime = el._time;
@@ -726,6 +880,9 @@ const engineHooks = {
726
880
  const committed =
727
881
  el._value !== asyncStartValue || el._time !== asyncStartTime || el._pendingValue === value;
728
882
  if (committed) stampWrite(el, "async", prev === undefined ? NO_VALUES : prev, value);
883
+ // Flight over either way — an equality-swallowed landing still spent
884
+ // the wall time (finalizeFlight only chains through a fresh stamp).
885
+ finalizeFlight(el);
729
886
  return;
730
887
  }
731
888
  // Landed through setSignal: reclassify its "write" stamp as an async
@@ -734,6 +891,7 @@ const engineHooks = {
734
891
  const change = el._devChange;
735
892
  if (change !== undefined && change.seq > asyncStartSeq && change.kind === "write")
736
893
  stampWrite(el, "async", NO_VALUES, value);
894
+ finalizeFlight(el);
737
895
  }
738
896
  };
739
897
  const attribution = {
@@ -742,6 +900,8 @@ const attribution = {
742
900
  frames.length = 0;
743
901
  scopeCosts.clear();
744
902
  writeCosts.clear();
903
+ waterfallLog = [];
904
+ hotCauses.clear();
745
905
  setAttributionHooks(engineHooks);
746
906
  },
747
907
  disable() {
@@ -750,6 +910,8 @@ const attribution = {
750
910
  frames.length = 0;
751
911
  scopeCosts.clear();
752
912
  writeCosts.clear();
913
+ waterfallLog = [];
914
+ hotCauses.clear();
753
915
  setAttributionHooks(null);
754
916
  },
755
917
  subscribe(listener) {
@@ -775,6 +937,15 @@ const attribution = {
775
937
  writes: [...writeCosts.values()].sort((a, b) => b.downstreamMs - a.downstreamMs)
776
938
  };
777
939
  },
940
+ waterfalls() {
941
+ return waterfallLog;
942
+ },
943
+ markFlight(flight, startedAt = now()) {
944
+ // Earliest wins: re-marking (a cache re-serving the same promise) must
945
+ // not move the origin later.
946
+ const existing = flightOrigins.get(flight);
947
+ if (existing === undefined || startedAt < existing) flightOrigins.set(flight, startedAt);
948
+ },
778
949
  format: formatRerun
779
950
  };
780
951
 
@@ -1920,6 +2091,12 @@ function commitPendingNode(n) {
1920
2091
  n._pendingValue = NOT_PENDING;
1921
2092
  // Set _modified for effects, but not for tracked effects (they handle their own scheduling)
1922
2093
  if (n._type && n._type !== EFFECT_TRACKED) n._modified = true;
2094
+ // A quiet re-ask classification preserved through a held landing dies
2095
+ // with the value commit — the commit IS the reveal (#3178). Gated on the
2096
+ // staged value: status propagation queues pending nodes whose windows
2097
+ // are still OPEN (no staged value), and their live classification must
2098
+ // survive this sweep.
2099
+ if (n._x) n._x._reask = false;
1923
2100
  }
1924
2101
  // The committed hold is the first observable answer for a loading-window
1925
2102
  // node — the window closes here, not at compute time (#2990). Unconditional
@@ -2187,6 +2364,13 @@ function transitionComplete(transition) {
2187
2364
  done && (transition._done = true);
2188
2365
  return done;
2189
2366
  }
2367
+ /** A fresh, unentered transaction (#3146): the optimistic store's truth
2368
+ * flight DECLARES an owned transaction instead of relying on whatever the
2369
+ * ambient adoption machinery stamped on its firewall. Activate it with
2370
+ * initTransition; it is a plain batch until then. */
2371
+ function createTransition() {
2372
+ return createBatch();
2373
+ }
2190
2374
  function currentTransition(transition) {
2191
2375
  while (transition._done && typeof transition._done === "object") transition = transition._done;
2192
2376
  return transition;
@@ -2851,12 +3035,14 @@ function addPendingSource(el, source) {
2851
3035
  return true;
2852
3036
  }
2853
3037
  function removePendingSource(el, source) {
2854
- if (!el._x?._pendingSources?.delete(source)) return false;
2855
- if (el._x?._pendingSources.size === 0) if (el._x !== null) el._x._pendingSources = undefined;
3038
+ const sources = el._x?._pendingSources;
3039
+ if (!sources?.delete(source)) return false;
3040
+ if (!sources.size) el._x._pendingSources = undefined;
2856
3041
  return true;
2857
3042
  }
2858
3043
  function clearPendingSources(el) {
2859
- el._x?._pendingSources?.clear();
3044
+ // This set is node-owned and never shared; dropping the sole reference
3045
+ // releases the set and every entry without a redundant clear() walk.
2860
3046
  if (el._x !== null) el._x._pendingSources = undefined;
2861
3047
  }
2862
3048
  // A rejection-pending only resolves through the settle sweep over the
@@ -2973,6 +3159,49 @@ function settleErroredDependents(el, error) {
2973
3159
  if (scheduled) schedule();
2974
3160
  }
2975
3161
  function settlePendingSource(el) {
3162
+ // Invariant: walking a settle implies truth exists. A caller reaching this
3163
+ // with an uninitialized source is announcing a settle that has not
3164
+ // happened — parked readers would wake into a value that was never
3165
+ // produced (the rc.5 regression: the recompute-side walk fired on a
3166
+ // projection driver whose first flight was superseded before any commit
3167
+ // reached the observable store). "Uninitialized" alone is not the tell,
3168
+ // though: a first landing whose commit is transition-held (streamed
3169
+ // hydration rides this) parks its value in `_pendingValue` with the flag
3170
+ // still set, and a comparator throw on that landing leaves the node
3171
+ // uninitialized but errored — both have real truth to reveal. Only an
3172
+ // uninitialized node with neither a held value nor an error is a settle
3173
+ // that never happened. Silent in production; loud in dev so a future
3174
+ // call site that violates the contract fails in its author's test run
3175
+ // instead of wedging a downstream app.
3176
+ {
3177
+ const sources = el._x?._pendingSources;
3178
+ if (
3179
+ el._statusFlags & STATUS_UNINITIALIZED &&
3180
+ el._pendingValue === NOT_PENDING &&
3181
+ !el._x?._error &&
3182
+ // A replacement source makes this a cleanup-only transfer: removing
3183
+ // self leaves the source and every propagated dependent parked. No
3184
+ // sources (or self alone) would release readers without truth.
3185
+ !(sources?.size && (sources.size > 1 || !sources.has(el)))
3186
+ ) {
3187
+ emitDiagnostic({
3188
+ code: "SETTLE_WALK_UNINITIALIZED_SOURCE",
3189
+ kind: "lifecycle",
3190
+ severity: "error",
3191
+ message:
3192
+ "[SETTLE_WALK_UNINITIALIZED_SOURCE] settlePendingSource was called on a source that " +
3193
+ "never produced a value. Settling parked readers requires truth to reveal — an " +
3194
+ "uninitialized source waking its dependents serves them its initial face instead of " +
3195
+ "settled data.",
3196
+ ownerId: el.id,
3197
+ ownerName: el._name
3198
+ });
3199
+ }
3200
+ }
3201
+ // The normal landing path already cleared the source's own set. Superseded
3202
+ // re-parks arrive here with an abandoned self entry, which must retire in
3203
+ // the same walk as its propagated copies.
3204
+ removePendingSource(el, el);
2976
3205
  let scheduled = false;
2977
3206
  let released;
2978
3207
  const visited = new Set();
@@ -3069,6 +3298,12 @@ function handleAsync(el, result, setter) {
3069
3298
  // fired _flightTeardown. A future non-recompute registration path must
3070
3299
  // release it here before overwriting _inFlight.
3071
3300
  ext(el)._inFlight = result;
3301
+ // Attribution hook: a new flight is registered. Fired here (not in the
3302
+ // branches below) so every flight shape — plain thenable, iterator, the
3303
+ // flattened combinations — is announced exactly once, while the recompute
3304
+ // frame that caused it is still on the engine's stack. Not inside a try
3305
+ // (#2883 — see attribution-hooks.ts).
3306
+ if (attrHooks !== null) attrHooks.flightStart(el, result);
3072
3307
  let syncValue;
3073
3308
  // Settle-time transition re-entry. The loading rail is invisible to
3074
3309
  // transactions (#2933): a boundary-caught first load never registers as an
@@ -3127,6 +3362,10 @@ function handleAsync(el, result, setter) {
3127
3362
  }
3128
3363
  settleTransition();
3129
3364
  notifyStatus(el, stillPending ? STATUS_PENDING : STATUS_ERROR, error);
3365
+ // A NotReady rejection is a landing into another pending source. The
3366
+ // rejected flight will never settle its self entry, so transfer ownership
3367
+ // after notifyStatus has propagated the replacement source.
3368
+ if (stillPending) settlePendingSource(el);
3130
3369
  el._time = clock;
3131
3370
  // A real error settles derivatively-pending dependents (notifyStatus
3132
3371
  // cleared their pending sources), so stranded lazy ones release here —
@@ -3141,8 +3380,16 @@ function handleAsync(el, result, setter) {
3141
3380
  if (el._flags & (REACTIVE_DIRTY | REACTIVE_OPTIMISTIC_DIRTY)) return;
3142
3381
  settleTransition();
3143
3382
  const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
3383
+ // Captured before clearStatus wipes it: a quiet re-ask's landing may be
3384
+ // transition-held below, and the displayed value keeps answering the same
3385
+ // question until the hold commits — the classification must survive to
3386
+ // that reveal or companion synchronization briefly classifies the held
3387
+ // old value as pending, a one-frame pulse to direct observers (#3178).
3388
+ // A truthy capture implies `_x` exists, so the restore writes it directly.
3389
+ const wasReask = el._x?._reask;
3144
3390
  trimStaleDeps(el);
3145
3391
  clearStatus(el);
3392
+ if (wasReask) el._x._reask = true;
3146
3393
  const lane = resolveLane(el);
3147
3394
  if (lane) lane._pendingAsync.delete(el);
3148
3395
  // Attribution hook: lets the engine snapshot state before the landing
@@ -3236,8 +3483,12 @@ function handleAsync(el, result, setter) {
3236
3483
  // (`_pendingValue` set above or inside setSignal) is not — the verdict's
3237
3484
  // held-value branch is window-gated, and commitPendingNode closes the
3238
3485
  // window when the hold commits, so no one-frame isPending pulse can leak
3239
- // to live observers between the landing and its commit (#2990).
3240
- if (el._pendingValue === NOT_PENDING) el._loading = false;
3486
+ // to live observers between the landing and its commit (#2990). The
3487
+ // quiet re-ask classification follows the same schedule (#3178).
3488
+ if (el._pendingValue === NOT_PENDING) {
3489
+ el._loading = false;
3490
+ if (wasReask) el._x._reask = false;
3491
+ }
3241
3492
  settlePendingSource(el);
3242
3493
  schedule();
3243
3494
  flush();
@@ -3701,8 +3952,7 @@ function recompute(el, create = false) {
3701
3952
  // settles synchronously, those dependents settle HERE — asyncWrite's
3702
3953
  // settlePendingSource walk never runs for a landing that was preempted
3703
3954
  // (#3181).
3704
- const wasPendingSource =
3705
- (el._statusFlags & STATUS_PENDING) !== 0 && el._x?._pendingSources?.has(el) === true;
3955
+ const wasPendingSource = el._x?._pendingSources?.has(el);
3706
3956
  // Re-ask classification lives in the verdict module; capture the flag before
3707
3957
  // the recompute wipes _flags below.
3708
3958
  const hadReask = (el._flags & REACTIVE_REASK) !== 0;
@@ -3816,6 +4066,9 @@ function recompute(el, create = false) {
3816
4066
  undefined,
3817
4067
  notReady ? el._x?._optimisticLane : undefined
3818
4068
  );
4069
+ // The replacement source is fully propagated now. If no new flight
4070
+ // re-owned self, retire the superseded flight and its dependent copies.
4071
+ if (notReady && wasPendingSource && !el._x?._inFlight) settlePendingSource(el);
3819
4072
  if (reaskChanged) GlobalQueue._repollVerdicts(el);
3820
4073
  }
3821
4074
  } finally {
@@ -3948,17 +4201,11 @@ function recompute(el, create = false) {
3948
4201
  // (el._x?._error re-set), so this only runs on a genuinely clean recovery.
3949
4202
  if (outgoingError !== undefined && !valueChanged && !el._x?._error)
3950
4203
  settleErroredDependents(el, outgoingError);
3951
- // Pending twin of the sweep above (#3181): a flight superseded by this
3952
- // synchronous settle leaves every registered dependent still flagged
3953
- // STATUS_PENDING with a pending source that will never land asyncWrite
3954
- // owns the walk only for flights that actually land. An unchanged value
3955
- // is the dangerous shape (a projection reconciling in place: a memo over
3956
- // it re-throws its cached NotReadyError forever, and every reader that
3957
- // suspended through that memo re-parks on the dead source), but the walk
3958
- // runs on the changed shape too, exactly as the landing path does —
3959
- // insertSubs notifies value SUBSCRIBERS, not pending REGISTRANTS, and
3960
- // the two sets only partially overlap.
3961
- if (wasPendingSource && !(el._statusFlags & STATUS_PENDING)) settlePendingSource(el);
4204
+ // #3181: a synchronous settle supersedes the old landing callback, so
4205
+ // recompute owns its pending-source sweep. An uninitialized node without
4206
+ // a replacement source still has no truth to reveal and must stay parked.
4207
+ if (wasPendingSource && !(el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)))
4208
+ settlePendingSource(el);
3962
4209
  }
3963
4210
  // Attribution hook: fired before the lane restore so `currentOptimisticLane`
3964
4211
  // still reflects THIS run's posture. The facts distinguish an overlay
@@ -5467,7 +5714,11 @@ function computePendingState(el) {
5467
5714
  ) {
5468
5715
  if (hasActiveOverride$1(el))
5469
5716
  return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._x?._overrideValue));
5470
- return true;
5717
+ // A quiet re-ask's held landing still answers the same question: the
5718
+ // classification survives the landing (asyncWrite) and dies with the
5719
+ // commit (commitPendingNode) — verdict-quiet through the reveal, like
5720
+ // the loading window above (#3178).
5721
+ if (!comp._x?._reask) return true;
5471
5722
  }
5472
5723
  return newQuestionInFlight(comp);
5473
5724
  }
@@ -6316,6 +6567,74 @@ function createSignal(first, second) {
6316
6567
  function createMemo(compute, options) {
6317
6568
  return accessor(computed(compute, options));
6318
6569
  }
6570
+ /**
6571
+ * Creates a reactive effect with **separate compute and effect phases**.
6572
+ *
6573
+ * - `compute(prev)` runs reactively — *put all reactive reads here*. The
6574
+ * returned value is passed to `effect` and is also the new "previous" value
6575
+ * for the next run.
6576
+ * - `effect(next, prev?)` runs imperatively (untracked) after the queue
6577
+ * flushes. *Put DOM writes / fetch / logging / subscriptions here.* It may
6578
+ * return a cleanup function which runs before the next effect or on
6579
+ * disposal.
6580
+ *
6581
+ * Reactive reads inside `effect` will *not* re-trigger this effect — that's
6582
+ * intentional. If you need a single-phase tracked effect, use
6583
+ * `createTrackedEffect` (with the tradeoffs noted there).
6584
+ *
6585
+ * Pass an `EffectBundle` (`{ effect, error }`) instead of a plain function to
6586
+ * intercept **compute-phase** errors — errors thrown by `compute` or arriving
6587
+ * from upstream reactive sources (including async rejections), which your own
6588
+ * code has no frame to `try/catch`. The `error` handler is the error arm of
6589
+ * the effect phase: it runs on the same schedule and in the same imperative,
6590
+ * writable scope as `effect` (setting error state via signals is fine), and
6591
+ * only for *settled* errors — a transient error that recovers before the
6592
+ * effect phase runs `effect` with the recovered value instead, and a held
6593
+ * transition defers it exactly as it defers `effect`. Without an `error`
6594
+ * handler a compute-phase error is logged and the effect simply skips that
6595
+ * run — a non-render effect's reactivity failing does not crash the app.
6596
+ * Rethrowing from `error` escalates it to the nearest error boundary
6597
+ * (halting the system if none exists).
6598
+ *
6599
+ * The **effect phase is different**: it is your own imperative code, so handle
6600
+ * failures with `try/catch` where they occur. An uncaught effect-phase throw
6601
+ * is treated as an unhandled application error — caught by the nearest
6602
+ * `createErrorBoundary`/`<Errored>`, and permanently halting the reactive
6603
+ * system if there is none. It is *not* routed to the bundle's `error` handler.
6604
+ *
6605
+ * ```typescript
6606
+ * createEffect<T>(compute, effectFn | { effect, error }, options?: EffectOptions);
6607
+ * ```
6608
+ * @param compute a function that receives its previous value and returns a new value used to react on a computation
6609
+ * @param effectFn a function that receives the new value and is used to perform side effects (return a cleanup function), or an `EffectBundle` with `effect` and `error` handlers
6610
+ * @param options `EffectOptions` -- name, defer, schedule, transparent
6611
+ *
6612
+ * @example
6613
+ * ```ts
6614
+ * const [count, setCount] = createSignal(0);
6615
+ *
6616
+ * createEffect(
6617
+ * () => count(), // compute: tracks `count`
6618
+ * value => console.log(value) // effect: side effect
6619
+ * );
6620
+ *
6621
+ * setCount(1); // logs 1 after the next flush
6622
+ * ```
6623
+ *
6624
+ * @example
6625
+ * ```ts
6626
+ * createEffect(
6627
+ * () => userId(),
6628
+ * id => {
6629
+ * const ctrl = new AbortController();
6630
+ * fetch(`/users/${id}`, { signal: ctrl.signal });
6631
+ * return () => ctrl.abort(); // cleanup before next run / disposal
6632
+ * }
6633
+ * );
6634
+ * ```
6635
+ *
6636
+ * @description https://docs.solidjs.com/reference/basic-reactivity/create-effect
6637
+ */
6319
6638
  function createEffect(compute, effectFn, options) {
6320
6639
  if (effectFn === undefined) {
6321
6640
  const message =
@@ -8521,33 +8840,43 @@ function readSource(target) {
8521
8840
  const hv = heldMaskView(target);
8522
8841
  if (hv !== null) return hv;
8523
8842
  }
8524
- // Signal-parity visibility (core read(): owner-context reads serve
8525
- // _pendingValue, context-free reads serve committed — effects recompute
8526
- // BEFORE commitPendingNodes in the flush, so the pending view must be
8527
- // servable). Drafts (setter window OR projection write-override) and
8528
- // owner-context reads see the pending backing; context-free reads see
8529
- // committed. Node reads apply the same rule, so both homes agree.
8530
- if (
8843
+ return pendingBackingVisible(target, false) ? target.pb : target.v;
8844
+ }
8845
+ /** The single pb-vs-committed visibility decision (#3147), shared by per-key
8846
+ * backing reads (readSource) and deep()/snapshot composition (snapshotWalk)
8847
+ * so the two reader families can never disagree about a HELD landing.
8848
+ *
8849
+ * Signal-parity visibility (core read(): owner-context reads serve
8850
+ * _pendingValue, context-free reads serve committed — effects recompute
8851
+ * BEFORE commitPendingNodes in the flush, so the pending view must be
8852
+ * servable). Drafts (setter window OR projection write-override) and
8853
+ * owner-context reads see the pending backing; context-free reads see
8854
+ * committed. Node reads apply the same rule, so all homes agree.
8855
+ *
8856
+ * `speculative` is deep()/snapshot's posture: an untrack/deep PEEK that sees
8857
+ * ordinary pending staging regardless of owner context (the documented
8858
+ * divergence from context-free per-key reads) — but never through a hold:
8859
+ * held truth stays masked exactly as it is for per-key readers. */
8860
+ function pendingBackingVisible(target, speculative) {
8861
+ return (
8531
8862
  target.pb !== null &&
8532
8863
  (inDraft(target) ||
8533
8864
  getWriteOverride() ||
8534
- // Owner-context readers see the pending backing — EXCEPT held truth
8535
- // on an optimistic family (#3164 fold): a live pb on an opt family
8536
- // outside the draft/write-override windows is a staged landing
8537
- // (tentative drafts never outlive their setter), and only the
8538
- // authoritative postures and latest() see it (the backing-level twin
8539
- // of core read()'s A17-for-held-truth arm; ordinary readers keep
8540
- // committed until the transaction's reveal).
8541
- (inOwnerContext() && !heldTruthMasked(target)) ||
8865
+ // Owner-context (and speculative-peek) readers see the pending
8866
+ // backing — EXCEPT held truth on an optimistic family (#3164 fold):
8867
+ // a live pb on an opt family outside the draft/write-override windows
8868
+ // is a staged landing (tentative drafts never outlive their setter),
8869
+ // and only the authoritative postures and latest() see it (the
8870
+ // backing-level twin of core read()'s A17-for-held-truth arm;
8871
+ // ordinary readers keep committed until the transaction's reveal).
8872
+ ((speculative || inOwnerContext()) && !heldTruthMasked(target)) ||
8542
8873
  // A projection's pending backing is authoritative-elect: serve it to
8543
8874
  // context-free readers too UNLESS a transition is holding the node
8544
8875
  // commits (downstream async hold — stale committed is the contract)
8545
8876
  // or the reader is a CHILDREN_FORBIDDEN scope, which never observes
8546
8877
  // its own unsettled write (#3082, signal parity per #3006).
8547
8878
  (target.fam !== null && !heldTruthMasked(target) && !foldHeld(target) && !inForbiddenScope()))
8548
- )
8549
- return target.pb;
8550
- return target.v;
8879
+ );
8551
8880
  }
8552
8881
  /** #3164 fold: HELD truth on an optimistic family — a pending backing
8553
8882
  * stamped by a live transition that retains optimism — is masked from
@@ -9275,10 +9604,15 @@ function snapshotWalk(value, seen, fam) {
9275
9604
  if (t === undefined) break;
9276
9605
  if (t.fam !== null) fam = t.fam;
9277
9606
  if (t.fam?.opt === true) (optOwners ??= []).push(t);
9607
+ // The shared visibility decision (#3147): the speculative peek serves
9608
+ // pending staging, but a HELD landing is masked to committed exactly as
9609
+ // it is for per-key readers — the two families must answer alike while
9610
+ // a transaction holds store landings.
9611
+ const usePB = pendingBackingVisible(t, true);
9278
9612
  // Snapshot runs mid-flush (tracked memos execute before commit), so a
9279
9613
  // pending prototype overlay must present as a REAL merged container.
9280
- if (t.ovl) materializePB(t);
9281
- const backing = t.pb ?? t.v;
9614
+ if (usePB && t.ovl) materializePB(t);
9615
+ const backing = usePB ? t.pb : t.v;
9282
9616
  if (backing === src) break;
9283
9617
  src = backing;
9284
9618
  }
@@ -10772,14 +11106,21 @@ function installNextBlockedHalf() {
10772
11106
  GlobalQueue._transitionBlocked = transition => {
10773
11107
  for (const store of transition._optimisticStores) {
10774
11108
  const t = store?.[$TARGET];
10775
- const fw = t?.fam?.node;
11109
+ const fam = t?.fam;
11110
+ const fw = fam?.node;
10776
11111
  // The hold exists to keep optimistic state alive until the store's own
10777
11112
  // truth lands (#2951). Once the family carries NO live overrides (a
10778
11113
  // landing consumed them, or they never existed), a pending firewall is
10779
11114
  // no reason to park the transaction — blocking then leaks it forever
10780
11115
  // when the in-flight question is never answered (undisposed fixtures).
10781
- if (fw != null && fw._statusFlags & STATUS_PENDING && familyHasLiveOverrides(t.fam))
10782
- return true;
11116
+ if (fw == null || !(fw._statusFlags & STATUS_PENDING)) continue;
11117
+ // Ownership is declared (#3146): only the flight's OWN transaction
11118
+ // parks on the flight (the #2951 anchor routed the bare write there).
11119
+ // A transaction that merely brushed the store never waits for truth
11120
+ // it does not carry.
11121
+ const ft = fam.ft != null ? liveTransition(fam.ft) : null;
11122
+ if (ft !== null && ft !== currentTransition(transition)) continue;
11123
+ if (familyHasLiveOverrides(fam)) return true;
10783
11124
  }
10784
11125
  return chained(transition);
10785
11126
  };
@@ -10837,15 +11178,35 @@ function createOptimisticStoreNext(first, second, options) {
10837
11178
  }
10838
11179
  if (derived) {
10839
11180
  const fn = first;
11181
+ // #3146: an async settle event belongs to the flight's OWN transaction.
11182
+ // A live declared one re-enters (a merge if the generic settle path
11183
+ // already entered a graph-stamped stranger — the landing supersedes any
11184
+ // recompute deriving from that stranger's world); a dead one renews (per
11185
+ // A18(1) each arrival reveals on its own schedule, so per-yield
11186
+ // transactions die with their commit and the next settle event opens the
11187
+ // flight's next one — still declared, never anonymous). An UNDECLARED
11188
+ // flight (loading window) keeps the ambient reveal (#2933: the loading
11189
+ // rail is transaction-invisible).
11190
+ const enterFlightTransition = () => {
11191
+ const declared = fam.ft;
11192
+ if (declared == null) return;
11193
+ let ft = liveTransition(declared);
11194
+ if (ft === null) fam.ft = ft = createTransition();
11195
+ fam.node._transition = ft;
11196
+ globalQueue.initTransition(ft);
11197
+ };
10840
11198
  // Landing router (#3164 fold ruling): while a transaction retains
10841
11199
  // optimistic edits on this family, truth landings stage INTO it and
10842
11200
  // reveal atomically at settle; with no retainer they commit immediately
10843
11201
  // under the authoritative posture (async commits land outside the
10844
- // computed's sync body, so the posture is re-applied here).
11202
+ // computed's sync body, so the posture is re-applied here) — inside the
11203
+ // flight-owned transaction (#3146). Sync commits (the derive's body,
11204
+ // owner is the firewall itself) reveal with their own recompute's flush.
10845
11205
  const wrapCommit = (write, value) => {
10846
11206
  const txn = retainingTransition(fam);
10847
- if (txn === null) runAuthoritative(write);
10848
- else stageLanding(fam, txn, value);
11207
+ if (txn !== null) return void stageLanding(fam, txn, value);
11208
+ if (getOwner() !== fam.node) enterFlightTransition();
11209
+ runAuthoritative(write);
10849
11210
  };
10850
11211
  // Draft writes (the derive mutating its draft, sync body and post-await
10851
11212
  // continuations alike) are the same truth channel per-operation: bind
@@ -10858,19 +11219,56 @@ function createOptimisticStoreNext(first, second, options) {
10858
11219
  if (txn === null) op();
10859
11220
  else runFolded(txn, op);
10860
11221
  };
11222
+ // Flight declaration (#3146): a recompute that registered a truth-flight
11223
+ // OWNS its transaction. The ask's transaction is recorded on the family
11224
+ // (created by the flight's own pending throw when none was ambient, the
11225
+ // causing write's/refresh's when one was — graph-driven causality) and
11226
+ // the firewall is stamped so every settle path resolves the flight's
11227
+ // transaction by construction, not by whatever last brushed the node.
11228
+ // When the ask took none (a dead stale stamp — the previous flight's,
11229
+ // cleared nowhere — bare-returns the pre-throw entry), the flight opens
11230
+ // its own here, same activation point as the pre-throw's creation: the
11231
+ // ambient batch (the causing write, same-tick bare optimism) adopts into
11232
+ // it exactly as it would have there, and the pending notification that
11233
+ // follows this unwind registers observers against it. The flight
11234
+ // also registers as its own async reporter: the transaction lives
11235
+ // exactly as long as the question is unanswered, observed or not — the
11236
+ // #2951 refetch-hold no longer depends on a tracked observer having
11237
+ // happened to register one. Loading-window flights declare nothing
11238
+ // (#2933: the loading rail is transaction-invisible); a sync run clears
11239
+ // the declaration.
11240
+ const declareFlight = self => {
11241
+ if (self._x?._inFlight == null) {
11242
+ if (!self._loading) fam.ft = null;
11243
+ return;
11244
+ }
11245
+ if (self._loading) return;
11246
+ let txn = activeTransition;
11247
+ if (txn === null) globalQueue.initTransition((txn = createTransition()));
11248
+ fam.ft = txn;
11249
+ self._transition = txn;
11250
+ let reporters = txn._asyncReporters.get(self);
11251
+ if (reporters === undefined) txn._asyncReporters.set(self, (reporters = new Set()));
11252
+ reporters.add(self);
11253
+ };
10861
11254
  let nodeOptions;
10862
11255
  if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined };
10863
11256
  if (options?.name) nodeOptions = { ...nodeOptions, name: options.name };
10864
11257
  const node = computed(() => {
10865
- runAuthoritative(() =>
10866
- runProjectionComputedNext(
10867
- store,
10868
- fn,
10869
- options?.key === undefined ? "id" : options.key,
10870
- wrapCommit,
10871
- aroundDraftWrite
10872
- )
10873
- );
11258
+ const self = getOwner();
11259
+ try {
11260
+ runAuthoritative(() =>
11261
+ runProjectionComputedNext(
11262
+ store,
11263
+ fn,
11264
+ options?.key === undefined ? "id" : options.key,
11265
+ wrapCommit,
11266
+ aroundDraftWrite
11267
+ )
11268
+ );
11269
+ } finally {
11270
+ declareFlight(self);
11271
+ }
10874
11272
  }, nodeOptions);
10875
11273
  node._config &= ~CONFIG_AUTO_DISPOSE;
10876
11274
  fam.node = node;
@@ -11071,13 +11469,18 @@ function stagedApply(cur, incoming, keyFn) {
11071
11469
  * for exactly the changed keys. Visible-view diffing keeps no-op writes from
11072
11470
  * entangling lanes (RUL-10 / opt R38). */
11073
11471
  function notifyOptimisticWrites(t, pb) {
11074
- // A bare write while the store's own truth is in flight rides THAT
11075
- // transaction (#2951, legacy parity): entangle the firewall's transition so
11076
- // the override survives until the refetch settles instead of flash-reverting
11472
+ // A bare write while the store's own truth is in flight rides the FLIGHT'S
11473
+ // OWN transaction (#2951 via the #3146 declaration): entangle it so the
11474
+ // override survives until the refetch settles instead of flash-reverting
11077
11475
  // at plain flush end. The blocked-check store-half keeps that transaction
11078
- // from settling while the firewall is pending.
11079
- const fw = t.fam?.node;
11080
- if (fw?._transition) globalQueue.initTransition(fw._transition);
11476
+ // from settling while the firewall is pending. Declared ownership replaces
11477
+ // the old circumstantial route through the firewall's `_transition` stamp,
11478
+ // which was whatever last brushed the node.
11479
+ const declared = t.fam?.ft;
11480
+ if (declared != null) {
11481
+ const ft = liveTransition(declared);
11482
+ if (ft !== null) globalQueue.initTransition(ft);
11483
+ }
11081
11484
  const old = t.v;
11082
11485
  // Patch channel (override-application site): the draft IS the intended
11083
11486
  // visible state; prev is the view before these overrides apply. Bypasses