@solidjs/signals 2.0.0-rc.10 → 2.0.0-rc.13

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.
@@ -226,20 +226,29 @@ const CONFIG_OVERRIDE_SUPERSEDED = 1 << 19;
226
226
  * transaction. A parked node (status propagation stamps `_transition`
227
227
  * without recomputing) recomputed when its source lands otherwise disposed
228
228
  * its committed children mid-hold, running their cleanups before the
229
- * transaction's atomic reveal. Cleared by `commitPendingNode`. */
229
+ * transaction's atomic reveal. Cleared by `commitPendingNode`. Transaction
230
+ * work only (A15 lane work and transaction work, #3698): zombies are parked
231
+ * by a pass under a held transaction; a pass over a lane parks a LANE frame
232
+ * instead (`CONFIG_LANE_FRAME`), whatever the node's kind. */
230
233
  const CONFIG_HELD_CHILDREN = 1 << 20;
231
234
  /** The frame parked in `_pendingFirstChild` / `_pendingDisposal` is a LANE
232
- * frame (#3662, A15 lanes corollary): an effect's lane pass direct-commits,
233
- * so the frame it replaces leaves the screen when the lane's queue applies
234
- * the run (A30) — not at the action's commit like #3404's transaction
235
- * zombies, and not at the pass (a held lane defers the run). Drained by the
236
- * lane's render entry the parking site pushed ahead of the new frame's
237
- * effects (cleanups before side effects), by `commitPendingNode` if a hold
238
- * commits the node first, or with the owner's death. While set the parked
239
- * frame is not a hold (the node is not queued or stamped for it), a
240
- * superseding pass disposes the never-shown live children on the spot, and a
241
- * lane-channel dirty on a member is cancelled (`laneZombie`). Effects only: a
242
- * memo's lane pass publishes an override (A17, #3479). */
235
+ * frame (#3662, #3698; A15 lane work and transaction work): a lane pass —
236
+ * on an effect or a memo alike — publishes into the lane's frame (an
237
+ * effect's run; a memo's derived override, A17), so the frame it replaces
238
+ * leaves the screen when the lane applies (A30) — not at the action's
239
+ * commit like #3404's transaction zombies, and not at the pass (a held lane
240
+ * defers the apply with the frame still displayed). Drained by the lane's
241
+ * render entry the parking site pushed ahead of the new frame's effects
242
+ * (cleanups before side effects), by `commitPendingNode` if a hold commits
243
+ * the node first, or with the owner's death. While set the parked frame is
244
+ * not a hold (the node is not queued or stamped for it — lane work never
245
+ * makes its node transaction work), a superseding pass disposes the
246
+ * never-shown live children on the spot, and a lane-channel dirty on a
247
+ * member is cancelled (`laneZombie`). Ruled 2026-09-28 (#3698): a pass is
248
+ * lane work or transaction work by its owner, never by node kind. #3662
249
+ * flagged effects only, and a memo's lane pass parked a transaction zombie
250
+ * that queued and stamped the memo as the action's pending node, so its
251
+ * next mainline recompute re-entered the hold. */
243
252
  const CONFIG_LANE_FRAME = 1 << 26;
244
253
  /** In-flight async node whose inputs were PUBLISHED while it was pending: a
245
254
  * batch or transaction committed with the node still `STATUS_PENDING` (an
@@ -461,22 +470,33 @@ function recordsChannel() {
461
470
  const g = globalThis;
462
471
  if (g[RECORDS]) return g[RECORDS];
463
472
  const listeners = new Map();
473
+ const bodyListeners = new Map();
464
474
  return (g[RECORDS] = {
465
- subscribe(type, listener) {
475
+ subscribe(type, listener, options) {
466
476
  const current = listeners.get(type);
467
- // Set semantics: one entry per function, however often it is passed.
468
- if (current === undefined) listeners.set(type, [listener]);
469
- else if (!current.includes(listener)) listeners.set(type, [...current, listener]);
477
+ // Set semantics: one entry per function, however often it is passed —
478
+ // the first subscription's options stand for it.
479
+ if (current === undefined || !current.includes(listener)) {
480
+ listeners.set(type, current === undefined ? [listener] : [...current, listener]);
481
+ if (options !== undefined && options.bodies) {
482
+ let wanting = bodyListeners.get(type);
483
+ if (wanting === undefined) bodyListeners.set(type, (wanting = new Set()));
484
+ wanting.add(listener);
485
+ }
486
+ }
470
487
  return () => {
471
488
  const list = listeners.get(type);
472
- if (list === undefined) return;
489
+ if (list === undefined || !list.includes(listener)) return;
473
490
  const next = list.filter(l => l !== listener);
474
491
  if (next.length > 0) listeners.set(type, next);
475
492
  else listeners.delete(type);
493
+ const wanting = bodyListeners.get(type);
494
+ if (wanting !== undefined && wanting.delete(listener) && wanting.size === 0)
495
+ bodyListeners.delete(type);
476
496
  };
477
497
  },
478
- observed(type) {
479
- return listeners.has(type);
498
+ observed(type, facet) {
499
+ return facet === "bodies" ? bodyListeners.has(type) : listeners.has(type);
480
500
  },
481
501
  emit(type, event, live) {
482
502
  const list = listeners.get(type);
@@ -502,9 +522,14 @@ const OBSERVE = {
502
522
  // empty HERE and gains its members by augmentation downstream.
503
523
  server: {},
504
524
  exclude(owner) {
505
- excludedOwners.add(owner);
525
+ markedOwners.set(owner, true);
506
526
  hasExclusions = true;
507
527
  },
528
+ include(owner) {
529
+ // No flag flip: with nothing excluded there is nothing to re-admit,
530
+ // and the walk stays short-circuited.
531
+ markedOwners.set(owner, false);
532
+ },
508
533
  isExcluded,
509
534
  ownerPath
510
535
  };
@@ -512,16 +537,23 @@ const OBSERVE = {
512
537
  //
513
538
  // An observer that lives inside the observed app (an adapter's panel,
514
539
  // devtools) marks its root; both channels check the subject's owner chain —
515
- // the same walk `ownerPath` already makes — and stay silent under it. The
516
- // flag short-circuits the walk for the common case of no exclusions.
517
- const excludedOwners = new WeakSet();
540
+ // the same walk `ownerPath` already makes — and stay silent under it. An
541
+ // observer that WRAPS the app (a toolbar around it) excludes its own root
542
+ // and includes the app's back: one map, `true` excluded / `false` included,
543
+ // and the nearest marker up the chain decides. The flag short-circuits the
544
+ // walk for the common case of no exclusions (an include with nothing
545
+ // excluded changes no verdict, so it does not flip it).
546
+ const markedOwners = new WeakMap();
518
547
  let hasExclusions = false;
519
548
  /** Events built for an excluded subject: never delivered, never reported. */
520
549
  const suppressedEvents = new WeakSet();
521
550
  function isExcluded(subject) {
522
551
  if (!hasExclusions || !subject) return false;
523
552
  let owner = "_parent" in subject ? subject : (subject._owner ?? null);
524
- for (; owner !== null; owner = owner._parent) if (excludedOwners.has(owner)) return true;
553
+ for (; owner !== null; owner = owner._parent) {
554
+ const marked = markedOwners.get(owner);
555
+ if (marked !== undefined) return marked;
556
+ }
525
557
  return false;
526
558
  }
527
559
  /** For engines that cache the verdict per node: is anything excluded at all? */
@@ -688,8 +720,14 @@ function throwPendingUntrackedRead(strictReadLabel, fields) {
688
720
  throw new Error(message);
689
721
  }
690
722
  function warnStrictReadUntracked(strictReadLabel, fields) {
723
+ // Name the value when the name says something — a `name` option or a store
724
+ // key (#3675). The constructors' defaults ("signal", "computed") would only
725
+ // restate what "reactive value" already says.
726
+ const nodeName = fields?.nodeName;
727
+ const named = nodeName !== undefined && nodeName !== "signal" && nodeName !== "computed";
691
728
  const message =
692
- `[STRICT_READ_UNTRACKED] Reactive value read directly in ${strictReadLabel} will not update. ` +
729
+ `[STRICT_READ_UNTRACKED] Reactive value${named ? ` "${nodeName}"` : ""} read directly in ` +
730
+ `${strictReadLabel} will not update. ` +
693
731
  `Move it into a tracking scope (JSX, a memo, or an effect's compute function).`;
694
732
  reportDiagnostic(
695
733
  emitDiagnostic({
@@ -1696,7 +1734,7 @@ function haltReactivity(cause) {
1696
1734
  let message = "[REACTIVITY_HALTED]";
1697
1735
  {
1698
1736
  message +=
1699
- " An uncaught error halted the reactive system. No further updates will be processed. Handle errors with createErrorBoundary/<Errored> or treat this as a crash.";
1737
+ " An uncaught error halted the reactive system. No further updates will be processed. Handle errors with <Errored> or treat this as a crash.";
1700
1738
  emitDiagnostic({
1701
1739
  code: "REACTIVITY_HALTED",
1702
1740
  kind: "error",
@@ -1932,7 +1970,11 @@ class GlobalQueue extends Queue {
1932
1970
  * commit whatever its relation to the committed value — a landing equal to
1933
1971
  * committed still differs from the override — then _supersedeOverride
1934
1972
  * decides. Installed with the optimistic engine; only reachable on a node
1935
- * that has an override. */
1973
+ * that has an override — or, from `mapArray` once a lane pass has run over
1974
+ * the map, on a per-slot signal (never CONFIG_OPTIMISTIC): the slot arm
1975
+ * publishes a lane pass's write as the slot's derived override, lands a
1976
+ * plain pass's write over one, and is the plain `setSignal` otherwise (F1,
1977
+ * see optimistic.ts landOnOverride). */
1936
1978
  static _landOnOverride = null;
1937
1979
  static _trackOptimisticStore = null;
1938
1980
  flush() {
@@ -2073,8 +2115,16 @@ class GlobalQueue extends Queue {
2073
2115
  // sweep or recompute wrote a node it owns): effects computed under it
2074
2116
  // since are its to apply, not this flush's — runEffect leaves them queued
2075
2117
  // and the next pass parks them with it (#3319). Everything computed
2076
- // mainline applies now.
2077
- scheduled = dirtyQueue._max >= dirtyQueue._min || activeTransition !== null;
2118
+ // mainline applies now. A write the finalize staged in the ambient
2119
+ // batch with no subscriber to dirty (an optimistic store settle's
2120
+ // keyset bump under a reader that never tracks the key set) is work
2121
+ // too — the fast drain and the park exit already count it — so the
2122
+ // next round commits it and the woken re-entry below does not adopt
2123
+ // it into a parked transaction it never belonged to (matrix F6).
2124
+ scheduled =
2125
+ dirtyQueue._max >= dirtyQueue._min ||
2126
+ activeTransition !== null ||
2127
+ this._batch._pendingNodes.length !== 0;
2078
2128
  // Run lane effects first (for ready lanes), then regular effects
2079
2129
  activeLanes.size && GlobalQueue._runLaneEffects(EFFECT_RENDER);
2080
2130
  this.run(EFFECT_RENDER);
@@ -2105,9 +2155,14 @@ class GlobalQueue extends Queue {
2105
2155
  // idle pass: entering adopts the ambient batch, and staged or dirty
2106
2156
  // ambient work would be held behind flights it never read. `scheduled`
2107
2157
  // is that test here — after the park exit as well as the normal one:
2108
- // it was recomputed from the heap this pass, every write since re-armed
2109
- // it, and optimistic ambient nodes reverted with the finalize — so a
2110
- // wake in a pass with work simply falls to the next. Entering re-arms
2158
+ // it was recomputed from the heap and the ambient batch's staged nodes
2159
+ // this pass, every write since re-armed it, and optimistic ambient
2160
+ // nodes reverted with the finalize — so a wake in a pass with work
2161
+ // simply falls to the next. (A staged node with no subscriber — the
2162
+ // finalize's keyset bump under a length-only reader — used to be
2163
+ // missed here: the wake adopted it, stamped it, and a later ambient
2164
+ // write to the same node joined the parked transaction and never
2165
+ // reverted; matrix F6.) Entering re-arms
2111
2166
  // it itself; a dead (completed) wake is a bare return in
2112
2167
  // initTransition, and the loop moves on to the next.
2113
2168
  while (!scheduled && !activeTransition && wokenTransitions.length)
@@ -4688,8 +4743,8 @@ function recompute(el, create = false) {
4688
4743
  let devChanged = false;
4689
4744
  if (attrHooks !== null) attrHooks.recomputeStart(el, create);
4690
4745
  // Lane posture is resolved BEFORE the previous frame is parked below: a
4691
- // lane pass on an effect direct-commits (#3662, see the parking site), so
4692
- // the decision must be known there. `lane` is applied to
4746
+ // lane pass parks a lane frame, not a transaction zombie (#3662, #3698;
4747
+ // see the parking site), so the decision must be known there. `lane` is applied to
4693
4748
  // `currentOptimisticLane` further down, once the previous posture is saved.
4694
4749
  let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY);
4695
4750
  let lane = null;
@@ -4738,20 +4793,30 @@ function recompute(el, create = false) {
4738
4793
  // Tracked effects run after finalizePureQueue, so dispose immediately
4739
4794
  // instead of deferring. Children built by an uncommitted recompute
4740
4795
  // (CONFIG_HELD_CHILDREN) die immediately too: no frame ever showed them.
4741
- // Everything else is the committed frame's and is deferred as zombies
4742
- // until this node's commit — a transaction-owned node included (#3404):
4743
- // a parked node's children predate the hold, and tearing them down when
4744
- // the source lands ran cleanups before the transaction's atomic reveal.
4745
- // A lane pass on an effect parks a LANE frame instead (CONFIG_LANE_FRAME,
4746
- // #3662; A15 lanes corollary): the frame it replaces leaves the screen
4747
- // when the lane's queue applies this run (A30) — not at the action's
4748
- // commit — and a held lane defers that with the frame still displayed.
4749
- // The drain is the lane's first render entry for this pass, pushed before
4750
- // the pass builds the new frame: cleanups before side effects — the
4751
- // retired frame's `onCleanup`s run ahead of every effect callback of the
4752
- // new frame. While the frame waits, the live children were never shown:
4753
- // a superseding pass disposes them here like held children, and the
4754
- // parked frame stays.
4796
+ // Everything else is the committed frame's. Which frame retires it is
4797
+ // decided by who owns the pass, never by the node's kind (A15 lane work
4798
+ // and transaction work, ruled 2026-09-28, #3698). Transaction work — a
4799
+ // pass under a held transaction — defers them as zombies until this
4800
+ // node's commit, a transaction-owned node included (#3404): a parked
4801
+ // node's children predate the hold, and tearing them down when the
4802
+ // source lands ran cleanups before the transaction's atomic reveal.
4803
+ // Lane work — a pass over a lane, effect or memo alike — parks a LANE
4804
+ // frame instead (CONFIG_LANE_FRAME, #3662, #3698): the frame it replaces
4805
+ // leaves the screen when the lane's queue applies this pass (A30) — not
4806
+ // at the action's commit — and a held lane defers that with the frame
4807
+ // still displayed. The drain is the lane's first render entry for this
4808
+ // pass, pushed before the pass builds the new frame: cleanups before
4809
+ // side effects — the retired frame's `onCleanup`s run ahead of every
4810
+ // effect callback of the new frame. While the frame waits, the live
4811
+ // children were never shown: a superseding pass disposes them here like
4812
+ // held children, and the parked frame stays. Zombies exist for
4813
+ // transaction work only: #3662 gated the lane frame on `isEffect`, and a
4814
+ // memo's lane pass (its value a derived override, A17) parked a
4815
+ // transaction zombie that made the memo a pending node of the action,
4816
+ // stamped it, and sent its next mainline recompute through the
4817
+ // stamped-memo arm above — a `Show` whose `when` getter owns a
4818
+ // compiler-emitted memo held an unrelated sync write for the action's
4819
+ // lifetime, against the #3460 ruling that a lane never holds a sync write.
4755
4820
  if (isEffect === EFFECT_TRACKED || el._config & (CONFIG_HELD_CHILDREN | CONFIG_LANE_FRAME))
4756
4821
  disposeChildren(el);
4757
4822
  else if (el._firstChild !== null || el._disposal !== null) {
@@ -4762,7 +4827,7 @@ function recompute(el, create = false) {
4762
4827
  el._disposal = null;
4763
4828
  el._firstChild = null;
4764
4829
  el._childCount = 0;
4765
- if (isEffect && lane) {
4830
+ if (lane) {
4766
4831
  el._config |= CONFIG_LANE_FRAME;
4767
4832
  findLane(lane)._effectQueues[0].push(() => {
4768
4833
  el._config &= ~CONFIG_LANE_FRAME;
@@ -6441,6 +6506,15 @@ function read(el) {
6441
6506
  return read(el);
6442
6507
  } else throw owner._x?._error;
6443
6508
  }
6509
+ // Ahead of the snapshot serve below: a component body's direct read is
6510
+ // wrong in the same way whether the pass is hydrating or not, and the
6511
+ // hydration pass is the console nobody is watching (#3675).
6512
+ if (strictRead)
6513
+ warnStrictReadUntracked(strictRead, {
6514
+ ownerId: c?.id,
6515
+ ownerName: c?._name,
6516
+ nodeName: owner?._name
6517
+ });
6444
6518
  if (snapshotCaptureActive && c && c._config & CONFIG_IN_SNAPSHOT_SCOPE) {
6445
6519
  const sv = el._x?._snapshotValue;
6446
6520
  if (sv !== undefined) {
@@ -6450,12 +6524,6 @@ function read(el) {
6450
6524
  return snapshot;
6451
6525
  }
6452
6526
  }
6453
- if (strictRead)
6454
- warnStrictReadUntracked(strictRead, {
6455
- ownerId: c?.id,
6456
- ownerName: c?._name,
6457
- nodeName: owner?._name
6458
- });
6459
6527
  const value = serve(el, c, owner, el._value);
6460
6528
  if (
6461
6529
  !c &&
@@ -6967,13 +7035,13 @@ export {
6967
7035
  currentTransition as M,
6968
7036
  NoOwnerError as N,
6969
7037
  OVERRIDE_UNDEFINED as O,
6970
- queuePendingNode as P,
6971
- latestReadActive as Q,
7038
+ assignOrMergeLane as P,
7039
+ queuePendingNode as Q,
6972
7040
  REACTIVE_DIRTY as R,
6973
7041
  STATUS_UNINITIALIZED as S,
6974
- REACTIVE_MANUAL_WRITE as T,
6975
- REACTIVE_OPTIMISTIC_DIRTY as U,
6976
- assignOrMergeLane as V,
7042
+ latestReadActive as T,
7043
+ REACTIVE_MANUAL_WRITE as U,
7044
+ REACTIVE_OPTIMISTIC_DIRTY as V,
6977
7045
  LANE_RUN as W,
6978
7046
  attrHooks as X,
6979
7047
  EFFECT_USER as Y,
@@ -19,12 +19,13 @@ import {
19
19
  /** A fallback shown for less than this is a flash: feedback for a wait too short to need it. */
20
20
  const FALLBACK_FLASH_MS = 150;
21
21
  /**
22
- * Under an owner the observer marked as its own (`OBSERVE.exclude`), or
23
- * framework plumbing itself (`CONFIG_PLUMBING` — the HMR memo between a
24
- * component's root and its body, which is nobody's node): the engine records
25
- * nothing about the node. Plumbing is a bit read and excludes the node alone,
26
- * not what it owns; the observer exclusion is cached per node once any
27
- * exists, before that a flag read.
22
+ * Under an owner the observer marked as its own (`OBSERVE.exclude`, and not
23
+ * re-admitted by a nearer `OBSERVE.include`), or framework plumbing itself
24
+ * (`CONFIG_PLUMBING` — the HMR memo between a component's root and its
25
+ * body, which is nobody's node): the engine records nothing about the node.
26
+ * Plumbing is a bit read and excludes the node alone, not what it owns; the
27
+ * observer exclusion is cached per node once any exists, before that a flag
28
+ * read.
28
29
  */
29
30
  function excludedNode(el) {
30
31
  if ((el._config & CONFIG_PLUMBING) !== 0) return true;
@@ -333,9 +334,16 @@ function originStart(ref) {
333
334
  return;
334
335
  }
335
336
  }
336
- const frame = { kind: ref.kind, at: ref.at ?? now() };
337
- if (ref.from !== undefined) frame.from = ref.from;
338
- const under = enclosingInteraction();
337
+ // The initial navigation is the document's: its request is the time origin
338
+ // unless the router says otherwise, and it left nowhere.
339
+ const initial = ref.initial === true;
340
+ const frame = { kind: ref.kind, at: ref.at ?? (initial ? 0 : now()) };
341
+ if (!initial && ref.from !== undefined) frame.from = ref.from;
342
+ // Declared beats ambient: a router that awaited before writing hands back
343
+ // the origin it captured in the request (`undefined` when the request ran
344
+ // under none — still a declaration); what is on the stack now is whatever
345
+ // happened to be running.
346
+ const under = "interaction" in ref ? interactionOf(ref.interaction) : enclosingInteraction();
339
347
  if (under !== undefined) frame.interaction = under;
340
348
  originFrames.push(frame);
341
349
  openNavigation(frame, ref);
@@ -369,7 +377,8 @@ function formatOrigin(origin) {
369
377
  notes.push(
370
378
  `redirected from ${redirects.map(hop => hop.to ?? hop.name ?? "?").join(" → ")}`
371
379
  );
372
- return `navigation to ${name}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`;
380
+ const initial = navStates.get(origin)?.event.initial === true;
381
+ return `${initial ? "initial " : ""}navigation to ${name}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`;
373
382
  }
374
383
  default:
375
384
  return "outside the reactive system";
@@ -1844,8 +1853,10 @@ function checkOptimisticRevert(el, shown, truth, how) {
1844
1853
  // derived override promotes rather than reverts. Same predicate the hold
1845
1854
  // census uses to skip them.
1846
1855
  if (!options.optimisticReverts || isCompanion(el)) return;
1856
+ // Method call, as every commit path does: store slot nodes share one
1857
+ // comparator that reads `this._host` (#3687).
1847
1858
  const equals = el._equals;
1848
- if (equals && equals(shown, truth)) return;
1859
+ if (equals && equals.call(el, shown, truth)) return;
1849
1860
  const source = nodeName(el);
1850
1861
  // The two values are user data: quoted only under `values: "full"`; the
1851
1862
  // other levels say what happened without saying what was shown.
@@ -2142,6 +2153,7 @@ function syncNavigation(state) {
2142
2153
  }
2143
2154
  function openNavigation(frame, ref) {
2144
2155
  const event = { at: frame.at, writes: 0, origin: frame };
2156
+ if (ref.initial === true) event.initial = true;
2145
2157
  if (frame.from !== undefined) event.from = frame.from;
2146
2158
  if (frame.interaction !== undefined) event.interaction = frame.interaction;
2147
2159
  const state = {
@@ -2354,7 +2366,10 @@ function settleNavigation(state, outcome, hold) {
2354
2366
  event.settledMs = now() - event.at;
2355
2367
  event.outcome = outcome;
2356
2368
  if (hold !== undefined) event.hold = hold;
2357
- for (const f of folds) f.navigation?.(event);
2369
+ // The initial declaration is not a navigation the person waited on: its
2370
+ // `settledMs` is document start → router built, which would read as the
2371
+ // route's responsiveness in the feedback tables. Consumers get the record.
2372
+ if (event.initial !== true) for (const f of folds) f.navigation?.(event);
2358
2373
  records.emit("navigation", event, undefined);
2359
2374
  trackGraph(event);
2360
2375
  // The interaction that performed it may have been waiting only on this.