diff --git a/.changeset/derived-store-propagated-error-once.md b/.changeset/derived-store-propagated-error-once.md new file mode 100644 index 0000000000..3c76682e2b --- /dev/null +++ b/.changeset/derived-store-propagated-error-once.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A sync derived store over a failing sync memo hands each new error to its readers once per flush, not twice: the error a source propagates to the derive now reaches the family's dedupe. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 415dc4fc72..cc5de18b55 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -1059,7 +1059,7 @@ export function propagateStatus(el: Computed, status: number, error: any): notifyStatus(sub, status, error); // A derived store's derive (store/projection.ts): marked, not re-run — // its leaves' readers are not its dependents, so the family walks them. - (sub as any).fam?.st(status); + (sub as any).fam?.st(status, error); } }); } diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index 50e7449cf4..da1b1492cc 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -205,11 +205,11 @@ function settleFamily(fam: StoreFamily): void { /** A source's status reached the derive by propagation (core * `propagateStatus`), its pass not re-run: the family learns it as from the * pass's own throw. */ -function familyStatus(this: StoreFamily, status: number): void { +function familyStatus(this: StoreFamily, status: number, error?: unknown): void { if (status & STATUS_PENDING) wakeFamily(this); - // (No error to compare: core propagates to the derive only an error it does - // not already carry, so its readers learn it here first.) - else if (status & STATUS_ERROR) errorFamily(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); } function forEachFamilyNode(fam: StoreFamily, fn: (n: Signal) => void): void { diff --git a/packages/signals/src/store/target.ts b/packages/signals/src/store/target.ts index 6c4c48fa4d..5d611e639a 100644 --- a/packages/signals/src/store/target.ts +++ b/packages/signals/src/store/target.ts @@ -41,7 +41,7 @@ export interface StoreFamily { * per-leaf chain; a target leaves it with its last node. */ live: Set; /** A source's status reaching the derive by propagation (`familyStatus`). */ - st?: (status: number) => void; + st?: (status: number, error?: unknown) => void; shallow?: boolean; /** Derive run counter (proj R37): the run whose draft is live. */ run?: number; diff --git a/packages/signals/tests/derived-store-error-delivered-once.test.ts b/packages/signals/tests/derived-store-error-delivered-once.test.ts index 87e7d30d21..ce3d7538ba 100644 --- a/packages/signals/tests/derived-store-error-delivered-once.test.ts +++ b/packages/signals/tests/derived-store-error-delivered-once.test.ts @@ -178,3 +178,46 @@ for (const kind of ["optimistic", "store"] as const) await tick(); expect(log).toEqual(["v 0", "ERR undefined"]); }); + +// A sync derive over a sync memo learns a new error twice in one flush: by +// propagation (core `propagateStatus` -> `familyStatus`) and by its own pass +// re-run against it. The second hand-off carries the same error, so the +// reader's error arm runs once per error. +for (const kind of ["optimistic", "store"] as const) + it(`${kind === "store" ? "a createStore(fn)" : "an optimistic store's"} sync derive over a failing sync memo delivers each error once`, async () => { + const log: string[] = []; + let set!: (v: number) => void; + const E1 = new Error("E1"); + const E2 = new Error("E2"); + createRoot(() => { + const [n, s] = createSignal(0); + set = s; + const m = createMemo(() => { + if (n() === 1) throw E1; + if (n() === 2) throw E2; + return n(); + }); + const make: any = kind === "store" ? createStore : createOptimisticStore; + const [st] = make( + (d: any) => { + d.v = m(); + }, + { v: -1 } + ); + createEffect(() => (st as any).v, { + effect: (v: any) => { + log.push(`v ${v}`); + }, + error: (e: any) => { + log.push(`ERR ${e?.message}`); + } + } as any); + }); + await tick(); + for (const v of [1, 2, 0]) { + set(v); + flush(); + await tick(); + } + expect(log).toEqual(["v 0", "ERR E1", "ERR E2", "v 0"]); + }); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 99c9d6c86f..f258ebe203 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4317,8 +4317,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): 32.43 -> 32.52 KB, recorded minified 100,748 -> 100,761 B (+13 B), measured locally (linux) at 32,508 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 (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 100,761 -> 100,797 B (+36 B), measured locally (linux) at 32,473 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. - limit: "32.52 KB", - capMinified: 100797, + // A propagated error reaches a derived store's readers once (#4463, 2026-10-30, over next @ 131ceb8c): 32.52 KB -> 32.54 KB, recorded minified 100,797 -> 100,803 B (+6 B), measured locally (linux) at 32,522 B brotli. familyStatus hands the error to errorFamily so a reader its derive's pass already handed it is skipped; the brotli move is layout from +2 B minified. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.54 KB", + capMinified: 100803, alias }, { @@ -6140,8 +6141,9 @@ module.exports = [ // at measured + 10 B rounded up to 0.01 KB; recorded minified never raised. // Batch re-record (2026-10-30, next @ 32163a9, size job 123956613767): 41.16 KB -> 41.23 KB, recorded minified 125,416 -> 125,435 B, measured in CI at 41,215 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. // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 125,435 -> 125,468 B (+33 B), measured locally (linux) at 41,178 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. - limit: "41.23 KB", - capMinified: 125468, + // A propagated error reaches a derived store's readers once (#4463, 2026-10-30, over next @ 131ceb8c): 41.23 KB -> 41.25 KB, recorded minified 125,468 -> 125,470 B (+2 B), measured locally (linux) at 41,231 B brotli. familyStatus hands the error to errorFamily so a reader its derive's pass already handed it is skipped; the brotli move is layout from +2 B minified. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "41.25 KB", + capMinified: 125470, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }