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/derived-store-derive-settle.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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… |
Expand Down
8 changes: 7 additions & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ import {
freshHook,
holdWorkHook,
guessing,
laneStageHook
laneStageHook,
storeParkHook
} from "./scheduler.js";
import type { Computed, Link, Owner } from "./types.js";

Expand Down Expand Up @@ -376,6 +377,11 @@ export function settlePendingSource(el: Computed<any>, source: Computed<any> = 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
Expand Down
6 changes: 6 additions & 0 deletions packages/signals/src/store/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions packages/signals/tests/derived-store-derive-settle.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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();
});
}
52 changes: 52 additions & 0 deletions packages/web/test/async-gen/store-derive-settle-e3.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {
// [s0=2 m0=async(s0)] <span fx[m0()]>isPending(m0)</span> :: 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)] <span>m0()</span> :: 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([]);
});
Loading