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/projection-sync-throw-reaches-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A `createProjection` whose re-run throws synchronously (for example, reading an entry that has since failed) now delivers the error to the readers of its fields, so the nearest `<Errored>` shows it instead of the screen keeping the last good value.
4 changes: 3 additions & 1 deletion packages/signals/src/store/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ export function runProjectionComputed<T extends object>(
// A flight went up (NotReady out of the pass — the derive's own, with
// the derive as its source): the leaves' readers learn it from here —
// they do not subscribe to the derive.
if (e instanceof NotReadyError && !first) wakeFamily(fam, e);
// A synchronous error the same way (#3956: a rerun that finds the
// entry failed throws): every live reader re-derives and meets it.
if (!first) e instanceof NotReadyError ? wakeFamily(fam, e) : errorFamily(fam);
throw e;
}
// A pass that returned with a flight up (an iterator's sync first yield):
Expand Down
90 changes: 90 additions & 0 deletions packages/signals/tests/store/projection-sync-throw-3956.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect, it } from "vitest";
import {
createErrorBoundary,
createLoadingBoundary,
createProjection,
createRenderEffect,
createRoot,
createSignal,
flush
} from "../../src/index.js";

const settle = async (ms: number) => {
const end = Date.now() + ms;
while (Date.now() < end) {
await new Promise(r => setTimeout(r, 5));
flush();
}
};

it("projection: a synchronous throw on a version-bumped rerun reaches Errored (#3956)", async () => {
const fetcher = (k: string) =>
new Promise<{ items: string[] }>((res, rej) =>
setTimeout(() => (k === "bad" ? rej(new Error("refused")) : res({ items: [`row-${k}`] })), 20)
);
const [key, setKey] = createSignal("good");
const [version, setVersion] = createSignal(0, { ownedWrite: true });
type E = {
status: "pending" | "ok" | "error";
p: Promise<{ items: string[] }>;
v?: { items: string[] };
e?: unknown;
};
const cache = new Map<string, E>();
const entry = (k: string) => {
let e = cache.get(k);
if (!e) {
const ne: E = { status: "pending", p: fetcher(k) };
ne.p.then(
v => {
ne.status = "ok";
ne.v = v;
setVersion(x => x + 1);
},
err => {
ne.status = "error";
ne.e = err;
setVersion(x => x + 1);
}
);
cache.set(k, ne);
e = ne;
}
return e;
};
let screen: unknown;
createRoot(() => {
const p = createProjection<{ value?: { items: string[] } }>(() => {
version();
const e = entry(key());
if (e.status === "error") throw e.e;
if (e.status === "ok") return { value: e.v! } as never;
return e.p.then(v => ({ value: v })) as never;
}, {});
const view = createErrorBoundary(
() =>
createLoadingBoundary(
() => (p.value?.items ?? []).join(","),
() => "PENDING"
),
() => "ERRORED"
);
createRenderEffect(
() => {
let v: any = view();
while (typeof v === "function") v = v();
return v;
},
v => {
screen = v;
}
);
});
flush();
await settle(60);
expect(screen).toBe("row-good");
setKey("bad");
flush();
await settle(200);
expect(screen).toBe("ERRORED");
});
Loading