diff --git a/.changeset/derived-store-derive-settle.md b/.changeset/derived-store-derive-settle.md new file mode 100644 index 0000000000..6bfe2dc6c5 --- /dev/null +++ b/.changeset/derived-store-derive-settle.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`createStore(fn)`: a derive that went pending on a source and was settled without re-running (a staged rejection, or a re-ask that returned to the committed question) settles its readers with it, as a memo's would: `isPending` ends and a refetch's rejection reaches them. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 7b33b3d6e7..7568f28807 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -53,7 +53,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:361` | boundaries.ts×2 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | seat.ts×1 truth.ts×1 verdict.ts×1 | fuzz-findings-l2.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:175` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | -| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:183` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:183` | async.ts×1 projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | | A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×1 seat.ts×1 verdict.ts×3 | boundary-not-born-held-3540.test.ts×1 createMemo.test.ts×1 error-staged-recovery.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 latest-uninitialized-over-latest-4362.test.ts×1 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | | A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 3eefc8c82d..f64af38161 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -48,7 +48,8 @@ import { freshHook, holdWorkHook, guessing, - laneStageHook + laneStageHook, + storeParkHook } from "./scheduler.js"; import type { Computed, Link, Owner } from "./types.js"; @@ -376,6 +377,11 @@ export function settlePendingSource(el: Computed, source: Computed = e enqueueSub(node); scheduled = true; } + // A derived store's derive pending by propagation (its pass not re-run): + // its readers wait on the derive itself, not on this source, so they + // settle with it, as a memo's would through its dependents (F-9, A9). + // Only a store build has one (`storeParkHook`). + else if (storeParkHook) (node as any).fam?.st(); if (node._x !== null) node._x._blocked = false; // Fully settled with nobody watching: release candidate (#2934). Checked // again at release time — deferred so unobserved() can't unlink subs diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index da1b1492cc..5285137c66 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -207,6 +207,12 @@ function settleFamily(fam: StoreFamily): void { * pass's own throw. */ function familyStatus(this: StoreFamily, status: number, error?: unknown): void { if (status & STATUS_PENDING) wakeFamily(this); + // No status: the source's settle walk retired the derive's pending, its + // pass not re-run (core `settlePendingSource`) — the readers waiting on the + // derive settle with it (F-9). A re-run would ask again: an async derive's + // new flight is a second wait, and would run ahead of a staged rejection's + // publish, which reaches the derive at the commit (F-12, D-41). + else if (!status) settleFamily(this); // The error rides along: a reader the derive's own pass already handed it // (a sync derive re-run in the same flush) is not handed it again. else if (status & STATUS_ERROR) errorFamily(this, error); diff --git a/packages/signals/tests/derived-store-derive-settle.test.ts b/packages/signals/tests/derived-store-derive-settle.test.ts new file mode 100644 index 0000000000..e414927160 --- /dev/null +++ b/packages/signals/tests/derived-store-derive-settle.test.ts @@ -0,0 +1,94 @@ +import { expect, it } from "vitest"; +import { + createErrorBoundary, + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush +} from "../src/index.js"; + +// A derived store over an async memo (`d.v = m()`) reads like the memo +// (async-gen E3). The derive went pending on the memo's flight, and the +// flight settled without the derive re-running: the settle retired the memo +// from the derive, and now settles the store's readers with it, as a memo's +// readers settle through its dependents — not by re-running the derive, +// whose new flight would be a second wait (F-9; #4463's error-once test) +// (F-12: a rejection staged in the transaction lands; its isPending twin, +// F-9, is pinned in web's async-gen store-derive-settle-e3.spec.ts). +const micro = () => Promise.resolve(); +const drain = async () => { + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 6; j++) await micro(); + flush(); + await new Promise(r => setTimeout(r, 0)); + } + flush(); +}; + +function app( + kind: "memo" | "store", + init: number, + poison: number | null, + leaf: (v: () => any) => string +) { + const flights: (() => void)[] = []; + const [s, setS] = createSignal(init); + let screen = ""; + const dispose = createRoot(d => { + const m = createMemo(() => { + const v = s() + 1; + return new Promise((res, rej) => + flights.push(() => (v % 4 === poison ? rej(new Error(`m0:${v}`)) : res(v))) + ); + }); + let view: () => any = m; + if (kind === "store") { + const [st] = createStore<{ v?: number }>(d => { + d.v = m(); + }, {}); + view = () => st.v; + } + const b = createErrorBoundary( + () => + createLoadingBoundary( + () => createMemo(() => leaf(view)), + () => "[L]" + ), + () => "[E]" + ); + createRenderEffect( + () => { + const r: any = b(); + return typeof r === "function" ? r() : r; + }, + v => { + screen = String(v); + } + ); + return d; + }); + flush(); + const settleAll = async () => { + await drain(); + while (flights.length) { + flights.shift()!(); + await drain(); + } + }; + return { flights, setS, settleAll, screen: () => screen, dispose }; +} + +for (const kind of ["memo", "store"] as const) { + it(`${kind}: a refetch's rejection, asked in the first answer's flush, shows the error`, async () => { + const a = app(kind, 2, 1, v => `v${v()}`); + a.flights.shift()!(); + await micro(); + a.setS(0); + await a.settleAll(); + expect(a.screen()).toBe("[E]"); + a.dispose(); + }); +} diff --git a/packages/web/test/async-gen/store-derive-settle-e3.spec.ts b/packages/web/test/async-gen/store-derive-settle-e3.spec.ts new file mode 100644 index 0000000000..fc76af3abb --- /dev/null +++ b/packages/web/test/async-gen/store-derive-settle-e3.spec.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment jsdom + */ +// async-gen rerun on rc.20, E3 (a derived store `createStore(d => { d.v = m() })` +// reads like `m`): two liveness failures with one cause. The derive went +// pending on m0's flight; the flight then settled (a rejection staged in its +// transaction, or a re-ask that returned to the committed question) without +// the derive re-running. The settle retired m0 from the derive, but the +// store's readers wait on the derive itself (projection.ts `wakeFamily`), and +// nothing retired that: `isPending` stayed true forever (F-9), and the staged +// rejection's transaction never landed, so the error never showed (F-12). The +// settle walk now settles the family with the derive (core +// `settlePendingSource` -> projection.ts `familyStatus`), as a memo's +// readers settle through its dependents. +import { expect, test } from "vitest"; +import { runScenario } from "./run.js"; + +const cases: Record = { + // [s0=2 m0=async(s0)] isPending(m0) :: S5 m A(s0=0) s0=2 + "isPending settles when the re-ask nets to the committed question": { + signals: [2], + memos: [{ async: true, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: null }], + tree: { + t: "leaf", + text: [{ how: "pending", src: { k: "m", i: 0 } }], + attrs: [{ how: "plain", src: { k: "m", i: 0 } }], + grouped: true + }, + steps: [ + { t: "settle", pick: 5 }, + { t: "micro" }, + { t: "action", s: 0, v: 0 }, + { t: "write", s: 0, v: 2 } + ] + }, + // [s0=2 s1=2 m0=async(s1 !%4=1)] m0() :: S4 m s1=0 + "a refetch's rejection reaches the store's readers": { + signals: [2, 2], + memos: [{ async: true, deps: [{ src: { k: "s", i: 1 }, how: "plain" }], poison: 1 }], + tree: { t: "leaf", text: [{ how: "plain", src: { k: "m", i: 0 } }], attrs: [], grouped: true }, + steps: [{ t: "settle", pick: 4 }, { t: "micro" }, { t: "write", s: 1, v: 0 }] + } +}; + +for (const [name, sc] of Object.entries(cases)) + test(`E3: ${name}`, async () => { + const memo = await runScenario(sc, {}); + const store = await runScenario(sc, { store: true }); + expect(store.screens).toEqual(memo.screens); + expect(store.final).toBe(memo.final); + expect(store.findings).toEqual([]); + });