diff --git a/.changeset/projection-staged-error-reader-link-4517.md b/.changeset/projection-staged-error-reader-link-4517.md new file mode 100644 index 0000000000..5170e6c34b --- /dev/null +++ b/.changeset/projection-staged-error-reader-link-4517.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A reader of a derived store whose derive carries a staged error (D-41) now links to the derive, as a reader of a pending or errored one does, so it re-runs when the derive's source answers. An `` mounted in the change that errored its content over a keyed `createProjection` showed its fallback for good; it now shows the answer. diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index d12476c0fd..9d318b674a 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -36,6 +36,7 @@ import { CONFIG_MANUAL_WRITE, CONFIG_OVERRIDE, CONFIG_PROMOTED, + CONFIG_STAGED_ERROR, CONFIG_VERDICT, EFFECT_RENDER, NOT_PENDING, @@ -1844,7 +1845,8 @@ function pullFamily(target: StoreTarget): void { // reader of a memo would — linked, so it re-runs at the landing (the // next settled pass pulls without linking and the stale link trims), a // verdict reader registered, a tracked pass suspended. - if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR)) { + // A staged error (D-41) throws at the pull, before any leaf would link the reader. + if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR) || fw._config & CONFIG_STAGED_ERROR) { // A render effect outside the flight's own flush is the frame, not a // derivation: it keeps what it shows and learns of the landing from the // leaves the landing changes (unchanged leaves say nothing — it is not a @@ -1858,7 +1860,7 @@ function pullFamily(target: StoreTarget): void { if ( c !== null && c._type === EFFECT_RENDER && - !(fw._statusFlags & STATUS_ERROR) && + fw._statusFlags & STATUS_PENDING && fw._config & CONFIG_HELD && flushTransaction !== txOf(fw) && !linkedTo(c, fw) diff --git a/packages/signals/tests/errored-born-staged-error-projection-4517.test.ts b/packages/signals/tests/errored-born-staged-error-projection-4517.test.ts new file mode 100644 index 0000000000..1fb438ad01 --- /dev/null +++ b/packages/signals/tests/errored-born-staged-error-projection-4517.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "vitest"; +import { + createErrorBoundary, + createLoadingBoundary, + createMemo, + createProjection, + createRenderEffect, + createRoot, + createSignal, + flush, + mapArray, + untrack +} from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 4; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +type Row = { id: number; name: string }; + +for (const keyed of [true, false]) + it(`an answer replaces the error a boundary was born with (projection: ${keyed}) (#4517)`, async () => { + const asks: { key: string; res: (v: Row[]) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const land = async (key: string, v: Row[]) => { + for (const a of asks.filter(a => a.key === key)) { + asks.splice(asks.indexOf(a), 1); + a.res(v); + } + await tick(); + }; + const [q, setQ] = createSignal(""); + let screen: unknown; + const dispose = createRoot(d => { + const src = createMemo(() => { + const s = q(); + if (s === "") return [] as Row[]; + if (s.length < 2) throw new Error("too short"); + return ask(`search:${s}`); + }); + const results: () => Row[] = keyed + ? (() => { + const p = createProjection(() => src(), [] as Row[], { key: "id" }); + return () => p; + })() + : src; + const show = createMemo(() => q() !== ""); + const out = createMemo(() => { + if (!show()) return undefined; + return untrack(() => + createErrorBoundary( + () => + createLoadingBoundary( + () => mapArray(results, r => r.name), + () => "loading" + ), + e => `E:${(e() as Error).message}` + ) + ); + }); + const resolve = (v: unknown): unknown => + typeof v === "function" ? resolve(v()) : Array.isArray(v) ? v.join("") : v; + createRenderEffect( + () => resolve(out()), + v => void (screen = v) + ); + return d; + }); + await tick(); + expect(screen).toBe(undefined); + setQ("a"); + await tick(); + expect(screen).toBe("E:too short"); + setQ("ap"); + await tick(); + expect(screen).toBe("E:too short"); + await land("search:ap", [ + { id: 1, name: "apple" }, + { id: 2, name: "apricot" } + ]); + expect(screen).toBe("appleapricot"); + dispose(); + }); diff --git a/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx b/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx new file mode 100644 index 0000000000..ccd46a1784 --- /dev/null +++ b/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx @@ -0,0 +1,79 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { expect, test } from "vitest"; +import { render } from "../src/index.js"; +import { + createMemo, + createProjection, + createSignal, + Errored, + flush, + For, + Loading, + Show +} from "solid-js"; + +type Row = { id: number; name: string }; + +const tick = async () => { + for (let i = 0; i < 4; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +test.each([true, false])( + "an answer replaces the error a boundary was born with (projection: %s)", + async projection => { + const asks: { key: string; res: (v: Row[]) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const land = async (key: string, v: Row[]) => { + for (const a of asks.filter(a => a.key === key)) { + asks.splice(asks.indexOf(a), 1); + a.res(v); + } + await tick(); + }; + const container = document.createElement("div"); + const [q, setQ] = createSignal(""); + const dispose = render(() => { + const src = createMemo(() => { + const s = q(); + if (s === "") return [] as Row[]; + if (s.length < 2) throw new Error("too short"); + return ask(`search:${s}`); + }); + const results: () => Row[] = projection + ? (() => { + const p = createProjection(() => src(), [] as Row[], { key: "id" }); + return () => p; + })() + : src; + return ( + + any) =>

{e().message}

}> + loading

}> + {r => {r.name}} +
+
+
+ ); + }, container); + await tick(); + expect(container.textContent).toBe(""); + setQ("a"); + await tick(); + expect(container.textContent).toBe("too short"); + setQ("ap"); + await tick(); + expect(container.textContent).toBe("too short"); + await land("search:ap", [ + { id: 1, name: "apple" }, + { id: 2, name: "apricot" } + ]); + expect(container.textContent).toBe("appleapricot"); + dispose(); + } +);