Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/guard-nodeless-store-staging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Prevent an untracked staged-store read beside `latest()` from throwing and halting reactivity when the store has no reactive node. A pass whose verdict posture is only inherited is now served the hold's own write from a node-less store key, as it is from a signal or a key with a node, so the landing frame no longer tears.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1

What is rendered from a display-ahead read reveals together with it: an async memo over `latest(x)` holds the `latest` view's reveal until it lands; a combined `isPending(() => [fast(), copy()])` over two async memos, one wrapped in a sync memo, stays `true` until both land, because a memo's value is one shared slot — it computes under its own hold posture, never under the posture of whichever reader pulled it. A node whose fetch a hold is waiting on is that hold's work, not a verdict reader: a pass that re-runs it while its fetch is in flight reads plainly — following the change it is fetching for — even if an earlier pass of it called `isPending` or `latest` (#3884). A memo's posture is what it reads: one that reads a verdict reader's value is a derivation of a display-ahead read, and a verdict reader itself — `createMemo(() => [held(), V()])` with `V = createMemo(() => isPending(x) && latest(e))` shows `V`'s verdict now, beside the screen's `held()`, as the inline probe and a render effect reading both do (#4050: a Provider's children memo over a router shell). **The probe decides the pass's posture in either read order (clarified 2026-10-28):** a pass that probes is a verdict reader whether the probe came before or after a plain read of the same held node — `[x(), isPending(x)]` and `[isPending(x), x()]` both show `[committed, true]` now and re-derive at the landing (A10) — unless the pass is the hold's own work: born in its world (D-27, #4341) or re-run by the write it holds (#3884 above). Those two read plainly; a probe does not rescue them. A pass that read two holds' display-ahead values is one reveal unit of both (A15 lanes corollary, #3335): it waits for either lane's flight, never showing one hold's `latest()` beside a half-computed derivation of the other's.

**A verdict posture outlives the pass that asked (ruled 2026-10-28):** a pass that asked (`isPending`, `latest`) is the hold's verdict reader on every later run of its node until one completes having asked nothing — that run still reads as a verdict reader; the next is an ordinary derivation. On each such run its plain read of a node the hold holds is the screen's (a frame reader, A10; re-derived at the landing as A15's stale reader) and it joins nothing, whoever dirtied it — a sync write, a boundary redraw, a landing. The hold's own work is not in this: a node whose fetch the hold waits on, or a pass born in its world, reads plainly whatever it asked before (the #3884 clause and D-27 #4341 above, unchanged). A boundary's display-ahead swap is this posture handed on: an `on` pass that read `latest`/`isPending` is a verdict reader, and the fallback swap it asks for runs as that reader's work — the output's pass reads nothing of the held frame (a pending tree is linked, not read) and its answer, the fallback, is shown now beside the held frame and stays the screen until the landing, instead of being staged with the write's frame as a plain `on`'s swap is (D-15): `[A] → [A + spinner] → [B + spinner] → [B + comments]`, never `[A] → [B + spinner]`. Only the `on` pass's asking counts: `latest()` inside the fallback is the fallback's own display-ahead, not the swap's — a plain `on` over an action-held input lands the swap with the frame whatever the fallback reads (#4337). Today two memories carry this — `CONFIG_VERDICT` left on the node by the pass that probed, and the node's listing in the hold's verdict lane (`fallback`'s `b._lane`, `recompute`'s head) — and the rule is the posture, not the membership. Pins: `tests/loading-on-rearm-3540.test.ts`, `tests/loading-on-frame-following-3540.test.ts` §4–5, `tests/loading-on-latest-fallback-4337.test.ts`. **An inherited posture is not a probe (2026-11-02, #4544):** a pass whose verdict posture is only inherited — it read a verdict memo, and neither it nor its node's last pass opened a window of its own — is the hold's work where it reads the hold's own write (a sync write the hold holds, not a flight's answer): it is served that staging and joins the hold, as a plain derivation does. One flush or several make no difference (F-10): before the flush parks, the pass reads the flush's own write as that staging, leaving the verdict lane an earlier read of the verdict memo seated it in, as the other read order keeps it out. Not a staging a window asked of under the hold that holds it (or in this flush, before one): `[latest(x), x()]` through a memo stays atomic (A10, D-26: `tests/latest-memo-inline-parity.test.ts`). That mark is the hold's — it expires when the hold lands or dissolves, and a later hold reads the node as never asked. A pass that probes in its own body stays the hold's verdict reader and reads the screen (F-6 over a staging it holds). When a frame born into the hold (a render effect never committed) reads such a reader's settled value and goes pending on what that display-ahead value asks for, the pending does not hold the hold (F-6: display-ahead readers never hold the change they read ahead of): the hold lands, the reader re-derives at the landing (A15) and the frame with it. A lane's reveal still waits on it (S-29). Pins: `tests/verdict-memo-held-branch-4544.test.ts`.
**A verdict posture outlives the pass that asked (ruled 2026-10-28):** a pass that asked (`isPending`, `latest`) is the hold's verdict reader on every later run of its node until one completes having asked nothing — that run still reads as a verdict reader; the next is an ordinary derivation. On each such run its plain read of a node the hold holds is the screen's (a frame reader, A10; re-derived at the landing as A15's stale reader) and it joins nothing, whoever dirtied it — a sync write, a boundary redraw, a landing. The hold's own work is not in this: a node whose fetch the hold waits on, or a pass born in its world, reads plainly whatever it asked before (the #3884 clause and D-27 #4341 above, unchanged). A boundary's display-ahead swap is this posture handed on: an `on` pass that read `latest`/`isPending` is a verdict reader, and the fallback swap it asks for runs as that reader's work — the output's pass reads nothing of the held frame (a pending tree is linked, not read) and its answer, the fallback, is shown now beside the held frame and stays the screen until the landing, instead of being staged with the write's frame as a plain `on`'s swap is (D-15): `[A] → [A + spinner] → [B + spinner] → [B + comments]`, never `[A] → [B + spinner]`. Only the `on` pass's asking counts: `latest()` inside the fallback is the fallback's own display-ahead, not the swap's — a plain `on` over an action-held input lands the swap with the frame whatever the fallback reads (#4337). Today two memories carry this — `CONFIG_VERDICT` left on the node by the pass that probed, and the node's listing in the hold's verdict lane (`fallback`'s `b._lane`, `recompute`'s head) — and the rule is the posture, not the membership. Pins: `tests/loading-on-rearm-3540.test.ts`, `tests/loading-on-frame-following-3540.test.ts` §4–5, `tests/loading-on-latest-fallback-4337.test.ts`. **An inherited posture is not a probe (2026-11-02, #4544):** a pass whose verdict posture is only inherited — it read a verdict memo, and neither it nor its node's last pass opened a window of its own — is the hold's work where it reads the hold's own write (a sync write the hold holds, not a flight's answer): it is served that staging and joins the hold, as a plain derivation does. One flush or several make no difference (F-10): before the flush parks, the pass reads the flush's own write as that staging, leaving the verdict lane an earlier read of the verdict memo seated it in, as the other read order keeps it out. Not a staging a window asked of under the hold that holds it (or in this flush, before one): `[latest(x), x()]` through a memo stays atomic (A10, D-26: `tests/latest-memo-inline-parity.test.ts`). That mark is the hold's — it expires when the hold lands or dissolves, and a later hold reads the node as never asked. A pass that probes in its own body stays the hold's verdict reader and reads the screen (F-6 over a staging it holds). When a frame born into the hold (a render effect never committed) reads such a reader's settled value and goes pending on what that display-ahead value asks for, the pending does not hold the hold (F-6: display-ahead readers never hold the change they read ahead of): the hold lands, the reader re-derives at the landing (A15) and the frame with it. A lane's reveal still waits on it (S-29). A store key is served the same with or without a node (R2, signal parity): a node-less staging is this flush's own write with no node a window could have asked through, so the posture alone decides (2026-11-04, #4681). Pins: `tests/verdict-memo-held-branch-4544.test.ts`, `tests/posture-store-parity.test.ts` S9.

**Collapses:** [A31](./SPEC-ASYNC-SEMANTICS.md#a31-a-memo-computes-under-its-own-lane-posture-never-its-pullers) (#3442), §20.1 question 3 ("Yeah definitely it is held. Consistent with any optimistic update"), OL-R24/R25/R34, recovery 6.2. **Status:** standing. A memo over a verdict memo: `tests/verdict-memo-under-held-memo-4050.test.ts`, `packages/web/test/held-shell-pending-feedback-4050.spec.tsx` (U-10). **Fixed 2026-11-01:** the verdict shortcut in `propagateStatus` (a probed source settling re-derives its verdict reader) now clears the settled source from the reader's pending sources — left in place, an errored probe target stranded a reader re-parked on a second source at that source's landing; `tests/verdict-shortcut-pending-source-liveness.test.ts`.

Expand Down
6 changes: 3 additions & 3 deletions packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| INV | 11 | 4 | 7 | 4 |
| RUL | 13 | 3 | 6 | 6 |
| R (CS) | 59 | 15 | 20 | 30 |
| R (OL) | 37 | 4 | 1 | 32 |
| R (OL) | 37 | 5 | 1 | 31 |
| R (OS) | 46 | 0 | 0 | 46 |
| R (PJ) | 37 | 2 | 5 | 32 |
| R (RS) | 38 | 0 | 2 | 36 |
Expand Down Expand Up @@ -142,7 +142,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| id | status | defined | cited in src | cited in tests | statement (at definition) |
|---|---|---|---|---|---|
| CS-R1 | live | `docs/rules-mining/core-store.md:9` | store.ts×2 | continuation-sibling-reader-4146.test.ts×2 flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.states.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** |
| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** |
| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | posture-store-parity.test.ts×1 syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** |
| CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | — | — | Corollary R2a (Ryan, 2026-08-17): **take no responsibility for mutation |
| CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.** |
| CS-R4 | live | `docs/rules-mining/core-store.md:22` | — | — | Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).** |
Expand Down Expand Up @@ -205,7 +205,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| id | status | defined | cited in src | cited in tests | statement (at definition) |
|---|---|---|---|---|---|
| OL-R1 | live | `docs/rules-mining/optimistic-lanes.md:11` | — | — | `createOptimistic(value \| fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. |
| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | — | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… |
| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | verdict.ts×1 | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… |
| OL-R3 | live | `docs/rules-mining/optimistic-lanes.md:19` | — | — | The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition'… |
| OL-R4 | live | `docs/rules-mining/optimistic-lanes.md:23` | seat.ts×3 | — | Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). |
| OL-R5 | live | `docs/rules-mining/optimistic-lanes.md:27` | lanes.ts×2 scheduler.ts×2 seat.ts×2 types.ts×1 verdict.ts×1 | — | An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `f… |
Expand Down
5 changes: 3 additions & 2 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2588,8 +2588,9 @@ export function stagedScreen(c: Computed<any>, el?: Signal<any> | Computed<any>)
bornInto(c, flushTransaction) ||
(el?._config! & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD ||
// #4544: this flush's own write, read by a pass whose posture is only
// inherited, is the frame's derivation, not a probe of it.
(observeFlightHook && holdStagingHook!(c, el!))
// inherited, is the frame's derivation, not a probe of it — a store's
// node-less staging (no `el`) too.
(observeFlightHook && holdStagingHook!(c, el))
)
return false;
// A transaction only a `latest()` read opened holds nothing: the staging
Expand Down
2 changes: 1 addition & 1 deletion packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export let keepReadersHook: ((el: Computed<any>) => void) | null = null;
export let attachHook: ((node: Computed<any>) => boolean) | null = null;
/** #4544 (verdict.ts). Null without the verdict engine. */
export let holdStagingHook:
| ((c: Computed<any>, el: Signal<any> | Computed<any>) => unknown)
| ((c: Computed<any>, el?: Signal<any> | Computed<any>) => unknown)
| null = null;
export function installVerdictHooks(
observeFlight: NonNullable<typeof observeFlightHook>,
Expand Down
27 changes: 16 additions & 11 deletions packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,22 +869,27 @@ function attachWaits(node: Computed<any>): boolean {
* that staging — held (`frameRead`), or this flush's before it parks
* (`stagedScreen`), leaving the verdict lane an earlier read of the verdict
* memo seated it in (D-25: either read order). A frame (a render effect, a
* boundary's structure) reads the screen as before. */
function holdStaging(c: Computed<any>, el: Signal<any> | Computed<any>): unknown {
const a = el._x?._askedIn;
* boundary's structure) reads the screen as before. No `el` is a store
* key's node-less staging (store.ts `readSource`): by construction this
* flush's own sync write to a plain store, with no node for a window to
* have asked through — the posture alone decides, as it does for the
* signal (R2, signal parity). */
function holdStaging(c: Computed<any>, el?: Signal<any> | Computed<any>): unknown {
const a = el?._x?._askedIn;
return (
!(
c._config & (CONFIG_OWN_PROBE | CONFIG_FRAME) ||
c._flags & (REACTIVE_WINDOW | REACTIVE_PROBE_UNANSWERED)
) &&
el._pendingValue !== NOT_PENDING &&
!(el._config & (CONFIG_ASYNC | CONFIG_OVERRIDE)) &&
a !== clock &&
!(
typeof a == "object" &&
resolveTx(a) ===
resolveTx(el._config & CONFIG_HELD ? el._x!._transaction! : flushTransaction!)
) &&
(!el ||
(el._pendingValue !== NOT_PENDING &&
!(el._config & (CONFIG_ASYNC | CONFIG_OVERRIDE)) &&
a !== clock &&
!(
typeof a == "object" &&
resolveTx(a) ===
resolveTx(el._config & CONFIG_HELD ? el._x!._transaction! : flushTransaction!)
))) &&
(!passLane || (passLane._parent!._verdict === passLane && (setPassLane(null), true)))
);
}
Expand Down
Loading
Loading