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-propagated-error-once.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,7 @@ export function propagateStatus(el: Computed<any>, 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);
}
});
}
8 changes: 4 additions & 4 deletions packages/signals/src/store/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>) => void): void {
Expand Down
2 changes: 1 addition & 1 deletion packages/signals/src/store/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface StoreFamily {
* per-leaf chain; a target leaves it with its last node. */
live: Set<StoreTarget>;
/** 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;
Expand Down
43 changes: 43 additions & 0 deletions packages/signals/tests/derived-store-error-delivered-once.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
10 changes: 6 additions & 4 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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 }
Expand Down
Loading