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/fix-derived-store-refetch-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Fix a derived store (`createStore(fn)`) whose re-fetch rejects never reaching `Errored`: a render effect outside the flight's flush kept showing the last value instead of throwing the derive's error, for sync-to-async and async-to-async derives alike. Memos were unaffected.
4 changes: 3 additions & 1 deletion packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,11 +1763,13 @@ function pullFamily(target: StoreTarget): void {
// One that already observes the flight (linked by the pass that saw it
// go up — the frame's hold, `blocked`) keeps observing it: a re-run for
// another reason (a guess it read, the seam) that dropped the link would
// release the hold with the flight still up.
// release the hold with the flight still up. An errored derive has no
// landing to learn of: the error is what the frame shows.
const c: any = context;
if (
c !== null &&
c._type === EFFECT_RENDER &&
!(fw._statusFlags & STATUS_ERROR) &&
fw._config & CONFIG_HELD &&
flushTransaction !== txOf(fw) &&
!linkedTo(c, fw)
Expand Down
110 changes: 110 additions & 0 deletions packages/signals/tests/store/derived-refetch-error-3887.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* #3887: a derived store whose re-fetch rejects must reach the error
* boundary, like a memo's does. The reader is the JSX shape — a render
* effect under `Errored` > `Loading` — which `pullFamily` lets keep its frame
* while the derive's flight is up; an errored derive has no landing for that
* frame to wait for.
*/
import {
createErrorBoundary,
createLoadingBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
createStore,
flush
} from "../../src/index.js";

async function settle() {
for (let i = 0; i < 4; i++) {
flush();
await Promise.resolve();
await new Promise(r => setTimeout(r));
}
flush();
}

function mount(read: () => unknown) {
const shown: unknown[] = [];
const dispose = createRoot(dispose => {
const view = createErrorBoundary(
() =>
createLoadingBoundary(
() => {
const text = createMemo(() => "content");
createRenderEffect(read, () => {});
return text;
},
() => "pending"
),
() => "error"
);
createRenderEffect(
() => {
let v: any = view();
while (typeof v === "function") v = v();
return v;
},
v => void shown.push(v)
);
return dispose;
});
return { shown, dispose };
}

function failingReplacement<T>() {
let reject!: (error: Error) => void;
const promise = new Promise<T>((_, fail) => (reject = fail));
return { promise, fail: () => reject(new Error("replacement failed")) };
}

describe("a failed derived-store refetch reaches the error boundary (#3887)", () => {
it("sync derive replaced by a rejecting async one", async () => {
const replacement = failingReplacement<{ value: number }>();
const [id, setId] = createSignal(0);
const [store] = createStore(() => (id() ? replacement.promise : { value: 0 }), { value: 0 });
const { shown, dispose } = mount(() => store.value);
await settle();
expect(shown.at(-1)).toBe("content");
setId(1);
await settle();
replacement.fail();
await settle();
expect(shown.at(-1)).toBe("error");
dispose();
});

it("async derive replaced by a rejecting async one", async () => {
const replacement = failingReplacement<{ value: number }>();
const [id, setId] = createSignal(0);
const [store] = createStore(
() => (id() ? replacement.promise : Promise.resolve({ value: 0 })),
{ value: -1 }
);
const { shown, dispose } = mount(() => store.value);
await settle();
expect(shown.at(-1)).toBe("content");
setId(1);
await settle();
replacement.fail();
await settle();
expect(shown.at(-1)).toBe("error");
dispose();
});

it("memo parity: a memo replaced by a rejecting async one", async () => {
const replacement = failingReplacement<number>();
const [id, setId] = createSignal(0);
const value = createRoot(() => createMemo(() => (id() ? replacement.promise : 0)));
const { shown, dispose } = mount(value);
await settle();
expect(shown.at(-1)).toBe("content");
setId(1);
await settle();
replacement.fail();
await settle();
expect(shown.at(-1)).toBe("error");
dispose();
});
});
Loading