From af1940e515f8a89da10714b543b454c6c9c8fc9e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:59:14 +0000 Subject: [PATCH 1/5] fix(signals): a derived store's derive re-runs when a source it pends on settles `createStore(fn)` over an async memo. The derive went pending on the memo's flight by propagation (`familyStatus` -> `wakeFamily`), and the store's readers were parked on the derive itself. When the flight then settled without the derive re-running (a rejection staged in its transaction, or a re-ask that returned to the committed question), the settle walk retired the memo from the derive but never reached the readers parked on it: `isPending` stayed true forever (F-9), and the staged rejection's transaction never landed, so the error never showed (F-12, D-41). The wake now marks the derive blocked, as a pass that threw is, so the settle re-runs it and its commit settles the family. Found by the async-gen rerun on rc.20 (pair E3, rows #1 and #9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/derived-store-derive-settle.md | 5 + packages/signals/src/store/projection.ts | 7 +- .../tests/derived-store-derive-settle.test.ts | 93 +++++++++++++++++++ .../async-gen/store-derive-settle-e3.spec.ts | 50 ++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 .changeset/derived-store-derive-settle.md create mode 100644 packages/signals/tests/derived-store-derive-settle.test.ts create mode 100644 packages/web/test/async-gen/store-derive-settle-e3.spec.ts diff --git a/.changeset/derived-store-derive-settle.md b/.changeset/derived-store-derive-settle.md new file mode 100644 index 0000000000..c5763c6177 --- /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) re-runs, so its readers settle: `isPending` ends and a refetch's rejection reaches them. diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index e6936f443c..33f8783434 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -23,7 +23,7 @@ import { STATUS_PENDING, STATUS_UNINITIALIZED } from "../core/constants.js"; -import { computed } from "../core/core.js"; +import { computed, ext } from "../core/core.js"; import { NotReadyError } from "../core/error.js"; import { addPendingSource, @@ -153,6 +153,11 @@ function wakeFamily(fam: StoreFamily): void { // reader the landing leaves unchanged does not re-run to clear it (#4305). const error = new NotReadyError(fw); fam.woke = true; + // Pending by propagation, its pass not re-run: a source's settle re-runs + // it (core `settlePendingSource`, as for a pass that threw), and its + // commit settles the family — the leaves' readers wait on the derive, not + // on that source (F-9, F-12). + ext(fw)._blocked = true; optHooks?.ask(fam); forEachFamilyNode(fam, n => { if (n._subs === null) return; 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..ff8a2c614e --- /dev/null +++ b/packages/signals/tests/derived-store-derive-settle.test.ts @@ -0,0 +1,93 @@ +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 re-runs the derive, whose commit settles the +// store's readers +// (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..bea4e9ff7c --- /dev/null +++ b/packages/web/test/async-gen/store-derive-settle-e3.spec.ts @@ -0,0 +1,50 @@ +/** + * @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 now re-runs the derive, as it does a pass that threw. +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([]); + }); From 96ada3c95947e1a1f370a599c1e5da438c9dac95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:34:00 +0000 Subject: [PATCH 2/5] chore(size): record the derived-store settle fix's sizes over next @ 7135a77a Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/scenarios.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ff93f301a5..3baa191b39 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1117,8 +1117,9 @@ module.exports = [ // 14.89 KB, recorded minified 43,874 -> 43,875 B; CI-measured at 14,875 B // (43,875 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "14.89 KB", - capMinified: 43875, + // A derived store's derive re-runs when a source it pends on settles (async-gen rerun #1/#9, 2026-10-30, over next @ 7135a77a): 14.89 -> 14.96 KB, recorded minified 43,875 -> 43,886 B, measured locally (linux) at 14,945 B brotli / 43,886 B minified (+11 B minified over next). A derive pending by propagation, its pass not re-run, now marks itself blocked so the source's settle re-runs it, as it does a pass that threw; the readers parked on the derive are retired at its commit (F-9, F-12). Store engine only. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "14.96 KB", + capMinified: 43886, alias }, { @@ -2729,11 +2730,12 @@ module.exports = [ // measured + 10 B rounded up to 0.01 KB; recorded minified never raised. // Batch re-record (2026-10-30, next @ 32163a9, size job 123956613767): cap unchanged, recorded minified 98,588 -> 98,607 B, measured in CI at 32,416 B brotli. Growth is the 2026-10-30 merges (#4424, #4436, #4416, #4442); #4416's truncation sweep is the +19 B minified on the hydrating scenarios. Cap at measured + 10 B rounded up to 0.01 KB where over; recorded minified = measured. // A held memo's parked reader re-derives at the seam (#4439, 2026-10-30, over next @ b249ad96): 32.42 KB -> 32.45 KB, recorded minified 98,607 -> 98,689 B (+82 B), measured locally (linux) at 32,433 B brotli. `laneSeams` also re-derives a reader whose pass read a held node, so `isPending` of a memo parked behind a held one turns true. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "32.45 KB", + // A derived store's derive re-runs when a source it pends on settles (async-gen rerun #1/#9, 2026-10-30, over next @ 7135a77a): 32.45 -> 32.49 KB, recorded minified 98,689 -> 98,700 B, measured locally (linux) at 32,476 B brotli / 98,700 B minified (+11 B minified over next). A derive pending by propagation, its pass not re-run, now marks itself blocked so the source's settle re-runs it, as it does a pass that threw; the readers parked on the derive are retired at its commit (F-9, F-12). Store engine only. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.49 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 97,045 -> 97,426 B (+240 B minified over next), measured locally (linux) at 31,976 B brotli (+109 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 98689, + capMinified: 98700, alias }, { @@ -4128,8 +4130,9 @@ module.exports = [ // 26.27 KB, recorded minified 79,985 -> 79,986 B; CI-measured at 26,257 B // (79,986 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "26.27 KB", - capMinified: 79986, + // A derived store's derive re-runs when a source it pends on settles (async-gen rerun #1/#9, 2026-10-30, over next @ 7135a77a): 26.27 -> 26.32 KB, recorded minified 79,986 -> 79,997 B, measured locally (linux) at 26,306 B brotli / 79,997 B minified (+11 B minified over next). A derive pending by propagation, its pass not re-run, now marks itself blocked so the source's settle re-runs it, as it does a pass that threw; the readers parked on the derive are retired at its commit (F-9, F-12). Store engine only. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "26.32 KB", + capMinified: 79997, alias }, { @@ -4301,8 +4304,9 @@ module.exports = [ // 32.43 KB, recorded minified 100,729 -> 100,748 B; CI-measured at 32,419 B // (100,748 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "32.43 KB", - capMinified: 100748, + // A derived store's derive re-runs when a source it pends on settles (async-gen rerun #1/#9, 2026-10-30, over next @ 7135a77a): 32.43 -> 32.50 KB, recorded minified 100,748 -> 100,759 B, measured locally (linux) at 32,482 B brotli / 100,759 B minified (+11 B minified over next). A derive pending by propagation, its pass not re-run, now marks itself blocked so the source's settle re-runs it, as it does a pass that threw; the readers parked on the derive are retired at its commit (F-9, F-12). Store engine only. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.50 KB", + capMinified: 100759, alias }, { From 5c6d4ccd75c3f130648e600d5f670f2a53e42c8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:59:50 +0000 Subject: [PATCH 3/5] fix(signals): a derived store's readers settle with its derive, not by a re-run The derive pending by propagation (familyStatus -> wakeFamily) was marked blocked so its source's settle re-ran it. For an async derive that re-run is a new flight: when the source's rejection was staged, the walk in stageError re-ran the derive in the same flush, ahead of the commit that publishes the error to it. The new flight woke the family again, so a reader beside isPending showed a second pending frame before the error (#4463's error-once test, D-41). The settle walk now settles the family instead (familyStatus with no status -> settleFamily) when it retires a derive's pending without a pass to re-run, as a memo's readers settle through its dependents (F-9, A9). The error still reaches the derive at the commit (F-12, D-41), and the re-ask that nets to the committed question ends isPending (F-9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/derived-store-derive-settle.md | 2 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/async.ts | 7 +++++-- packages/signals/src/store/projection.ts | 13 +++++++------ .../tests/derived-store-derive-settle.test.ts | 5 +++-- .../test/async-gen/store-derive-settle-e3.spec.ts | 4 +++- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.changeset/derived-store-derive-settle.md b/.changeset/derived-store-derive-settle.md index c5763c6177..6bfe2dc6c5 100644 --- a/.changeset/derived-store-derive-settle.md +++ b/.changeset/derived-store-derive-settle.md @@ -2,4 +2,4 @@ "@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) re-runs, so its readers settle: `isPending` ends and a refetch's rejection reaches them. +`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 5668d65508..8eaadf883f 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 bf8b254895..20450e5c8d 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -373,9 +373,12 @@ export function settlePendingSource(el: Computed, source: Computed = e if (node._config & CONFIG_HELD) joinFuture(txOf(node)); if (node._x?._blocked) { enqueueSub(node); - scheduled = true; + node._x._blocked = !(scheduled = true); } - if (node._x !== null) node._x._blocked = false; + // 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). + else (node as any).fam?.st(); // Fully settled with nobody watching: release candidate (#2934). Checked // again at release time — deferred so unobserved() can't unlink subs // lists this walk is still iterating. diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index 4b9686548e..5285137c66 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -23,7 +23,7 @@ import { STATUS_PENDING, STATUS_UNINITIALIZED } from "../core/constants.js"; -import { computed, ext } from "../core/core.js"; +import { computed } from "../core/core.js"; import { NotReadyError } from "../core/error.js"; import { addPendingSource, @@ -153,11 +153,6 @@ function wakeFamily(fam: StoreFamily): void { // reader the landing leaves unchanged does not re-run to clear it (#4305). const error = new NotReadyError(fw); fam.woke = true; - // Pending by propagation, its pass not re-run: a source's settle re-runs - // it (core `settlePendingSource`, as for a pass that threw), and its - // commit settles the family — the leaves' readers wait on the derive, not - // on that source (F-9, F-12). - ext(fw)._blocked = true; optHooks?.ask(fam); forEachFamilyNode(fam, n => { if (n._subs === null) return; @@ -212,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 index ff8a2c614e..e414927160 100644 --- a/packages/signals/tests/derived-store-derive-settle.test.ts +++ b/packages/signals/tests/derived-store-derive-settle.test.ts @@ -13,8 +13,9 @@ import { // 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 re-runs the derive, whose commit settles the -// store's readers +// 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(); 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 index bea4e9ff7c..fc76af3abb 100644 --- a/packages/web/test/async-gen/store-derive-settle-e3.spec.ts +++ b/packages/web/test/async-gen/store-derive-settle-e3.spec.ts @@ -9,7 +9,9 @@ // 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 now re-runs the derive, as it does a pass that threw. +// 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"; From 8d3a32e5c7bfa9cc9efe72f922c8d3674fd851aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:10:43 +0000 Subject: [PATCH 4/5] chore(size): record the derived-store settle fix's sizes over next @ d0fc2851 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/scenarios.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 7bcbb47f12..dcd96e3901 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4146,8 +4146,9 @@ module.exports = [ // 0.01 KB; recorded minified never raised. // A tick whose writes net to the committed value pends no errored re-run (D-8, async-gen rerun on 5e68925c) (2026-10-30, over next @ 7135a77a): 26.27 -> 26.31 KB, recorded minified 79,986 -> 79,999 B (+13 B), measured locally (linux) at 26,294 B brotli. A signal read whose staging equals its committed value is not a read of the frame's future (`read`): +13 B minified, the rest brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record after the 2026-10-30 merges (#4454, #4457, #4461; over next @ 563b6b0): 26.31 KB -> 26.33 KB, recorded minified 79,999 -> 80,035 B (+36 B), measured locally (linux) at 26,312 B brotli. Growth is #4454's `stagedScreen` held-node check (+20 B minified in core.js) and #4457's same-error skip in `errorFamily`; together they put next over this cap. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "26.33 KB", - capMinified: 80035, + // A derived store's readers settle with its derive (async-gen rerun #1/#9, 2026-10-30, over next @ d0fc2851): 26.33 KB -> 26.35 KB, recorded minified 80,035 -> 80,056 B, measured locally (linux) at 26,336 B brotli / 80,056 B minified (+8 B minified over next). A derive pending by propagation, its pass not re-run, now settles its family when its source's settle walk retires that pending (core settlePendingSource -> familyStatus), as a memo's readers settle through its dependents: isPending ends at a re-ask that nets to the committed question, and a staged rejection lands (F-9, F-12, A9). The derive is not re-run, so an async derive's error reaches its readers once (D-41, #4463). Core floors -3 B minified. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "26.35 KB", + capMinified: 80056, alias }, { From c3c7055667dad78c74e33d281b7c78442027dec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:34:11 +0000 Subject: [PATCH 5/5] fix(signals): settle a derive's family only in a store build On next @ 643a7a7 the ungated `else (node as any).fam?.st()` laid out +20 B brotli on hello world (9,993 B, over its 9.98 KB cap) from -3 B minified. A derive's family exists only when the store module is loaded, which is what sets `storeParkHook`, so the arm is gated on it and folds away in store-free bundles: every one is 0 B vs next, hello world included (9,973 B). The settle walk's `_blocked` clear is back to next's form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/async.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index abbbea32aa..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"; @@ -374,12 +375,14 @@ export function settlePendingSource(el: Computed, source: Computed = e if (node._config & CONFIG_HELD) joinFuture(txOf(node)); if (node._x?._blocked) { enqueueSub(node); - node._x._blocked = !(scheduled = true); + 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). - else (node as any).fam?.st(); + // 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 // lists this walk is still iterating.