diff --git a/.changeset/latest-error-publication.md b/.changeset/latest-error-publication.md new file mode 100644 index 000000000..c478c8eea --- /dev/null +++ b/.changeset/latest-error-publication.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Publish errors exposed through `latest` with their reader's update, including error fallback changes and recovery, instead of accidentally holding them with the input update. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 80538da36..318d5986f 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -105,7 +105,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | D-38 | live | `docs/RULES-DERIVED.md:245` | — | errored-reader-4486.test.ts×1 | Effect error phases; a comparator throw is a compute error; an escaping error halts | | D-39 | live | `docs/RULES-DERIVED.md:253` | boundaries.ts×9 core.ts×1 lanes.ts×1 scheduler.ts×3 types.ts×1 store.ts×1 | derived-store-recovery-wake.test.ts×1 error-boundary-superseded-staged-error-4527.test.ts×1 error-is-an-answer.test.ts×2 error-staged-recovery.test.ts×1 errored-heal-memo-reader-4644.test.ts×2 errored-list-recovery-held-4526.test.ts×1 errored-memo-over-output-held-reject-4640.test.ts×1 errored-over-rearmed-loading-4384.test.ts×1 errored-reader-4486.test.ts×3 errored-recovery-fresh-loading-4627.test.ts×2 errored-recovery-stale-memo-4592.test.ts×2 errored-recovery-wedge-4403-4404-4405.test.ts×5 latest-memo-inline-parity.test.ts×2 latest-uninitialized-over-latest-4362.test.ts×1 createProjection.async.test.ts×1 derived-store-heal-4451.test.ts×1 | An error fallback is the display; it stays until a new answer replaces the error | | D-40 | live | `docs/RULES-DERIVED.md:293` | constants.ts×1 core.ts×1 deferred.ts×2 signals.ts×1 | create-deferred-3710.test.ts×1 create-deferred.test.ts×1 | A deferred source lags its readers and never leads them | -| D-41 | live | `docs/RULES-DERIVED.md:257` | boundaries.ts×5 async.ts×5 constants.ts×1 core.ts×11 scheduler.ts×3 seat.ts×10 types.ts×1 verdict.ts×4 signals.ts×1 projection.ts×3 store.ts×2 | derived-store-error-delivered-once.test.ts×2 derived-store-recovery-wake.test.ts×1 error-boundary-superseded-staged-error-4527.test.ts×3 error-is-an-answer.test.ts×3 error-staged-recovery.test.ts×1 errored-held-mount-staged-error-4480.test.ts×3 errored-latest-obsolete-flight-4568.test.ts×1 errored-list-recovery-held-4526.test.ts×2 errored-memo-over-output-held-reject-4640.test.ts×1 errored-reader-4486.test.ts×1 errored-recovery-fresh-loading-4627.test.ts×4 errored-recovery-wedge-4403-4404-4405.test.ts×6 errored-staged-source-4364.test.ts×1 held-sync-throw-4109.test.ts×2 hold-recovers-landing-reruns-4469.test.ts×3 latest-beside-staged-read-4365.test.ts×1 latest-derived-held-error-4106.test.ts×1 latest-derived-held-error-guess-lane-4106.test.ts×1 latest-derived-sync-error-4377.test.ts×1 loading-reveal-held-outside-4593.test.ts×1 optimistic-held-error-untracked-read-4489.test.ts×3 settle-diamond-revisit.test.ts×1 staged-error-derivation.test.ts×1 staged-error-rerun-4109.test.ts×1 staged-error-through-async-memo-4640.test.ts×1 writable-memo-write-clears-error-4110.test.ts×1 | An error is an answer: it ends the wait like a value, and it shows when a value would | +| D-41 | live | `docs/RULES-DERIVED.md:257` | boundaries.ts×5 async.ts×5 constants.ts×1 core.ts×11 scheduler.ts×3 seat.ts×10 types.ts×1 verdict.ts×4 signals.ts×1 projection.ts×3 store.ts×2 | derived-store-error-delivered-once.test.ts×2 derived-store-recovery-wake.test.ts×1 error-boundary-superseded-staged-error-4527.test.ts×3 error-is-an-answer.test.ts×3 error-staged-recovery.test.ts×1 errored-held-mount-staged-error-4480.test.ts×3 errored-latest-obsolete-flight-4568.test.ts×1 errored-list-recovery-held-4526.test.ts×2 errored-memo-over-output-held-reject-4640.test.ts×1 errored-reader-4486.test.ts×1 errored-recovery-fresh-loading-4627.test.ts×4 errored-recovery-wedge-4403-4404-4405.test.ts×6 errored-staged-source-4364.test.ts×1 held-sync-throw-4109.test.ts×2 hold-recovers-landing-reruns-4469.test.ts×3 latest-beside-staged-read-4365.test.ts×1 latest-derived-held-error-4106.test.ts×1 latest-derived-held-error-guess-lane-4106.test.ts×1 latest-derived-sync-error-4377.test.ts×2 loading-reveal-held-outside-4593.test.ts×1 optimistic-held-error-untracked-read-4489.test.ts×3 settle-diamond-revisit.test.ts×1 staged-error-derivation.test.ts×1 staged-error-rerun-4109.test.ts×1 staged-error-through-async-memo-4640.test.ts×1 writable-memo-write-clears-error-4110.test.ts×1 | An error is an answer: it ends the wait like a value, and it shows when a value would | | D-42 | live | `docs/RULES-DERIVED.md:275` | boundaries.ts×2 async.ts×3 core.ts×5 dev.ts×1 effect.ts×2 owner.ts×1 verdict.ts×1 index.ts×1 signals.ts×1 | caught-reactive-read-diagnostic.test.ts×1 errored-reader-4486.test.ts×2 try-catch-reactive-read.test.ts×2 | A reactive read is not an `await`: `try/catch` around it is not a channel | ## A — spec propositions diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 8836e8f2e..55844d470 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -114,6 +114,8 @@ interface Boundary { * beside the held frame — not the frame's. `_lane` is set when the arm * flips the boundary and consumed by the swap pass. */ _armLane: Transaction | null; + /** Next swap's lane. An error caught ahead carries the reader's lane; + * recovery of that fallback stays in its output's lane until it ends. */ _lane: Transaction | null; /** DEV: the arm was display-ahead (a lane) — the fallback shows now, * whatever holds the frame; no LOADING_ON_OUTSIDE_HOLD report. */ @@ -193,6 +195,11 @@ function record(b: Boundary, node: Computed, source = node): void { * decides). */ function caught(b: Boundary, node: Computed, error: unknown, source = node): void { if (b._type === STATUS_ERROR) { + // The error's source is the retry target, but the reader's selected + // outcome owns publication. Do not lose a latest/optimistic lane when + // converting its error into fallback content. + const lane = passLane ?? (node._x?._transaction?._lane ? txOf(node) : null); + if (!b._fallback) b._lane = lane; // An error boundary waits on what THREW (`StatusError.source`), not on // the reader that saw it, while the error is settled only when its // source is neither errored nor in flight and the reader is not pending @@ -205,6 +212,9 @@ function caught(b: Boundary, node: Computed, error: unknown, source = node) source = (error as { source?: Computed })?.source ?? node; if (source._flags & REACTIVE_DISPOSED) source = node; const caught = unwrapStatusError(error); + // Subsequent errors update the existing fallback through this signal; + // its message must publish with the swap, including after a first show. + if (lane) laneStageHook!(b._error! as Computed, lane, true, false); setSignal(b._error!, caught); reportClientError(caught, b._owner, source); } @@ -276,6 +286,7 @@ function catchStatus( b._readers.delete(node) && !prune(b, 0) ) { + if (b._type === STATUS_ERROR) b._lane = passLane; if (b._fallback) redraw(b); if (revealHooks && b._reveal !== null) revealHooks._changed(b._reveal); } @@ -792,6 +803,16 @@ function createBoundary( // re-arm's swap is the change's work (`arm`), until that swap commits // (D-13, #3540). const self = getOwner() as Computed; + // Reading the retained tree need not read the leaf that recovered. + // Keep the ahead swap's lane for recovery as well as for the fallback; + // otherwise that successful answer is parked in the input's hold. + if (b._type === STATUS_ERROR && b._fallback && self._config & CONFIG_OVERRIDE) + b._lane ??= txOf(self); + if (b._type === STATUS_ERROR && b._lane !== null) { + setPassLane(b._lane); + self._flags |= REACTIVE_LANE_READ; + b._lane = null; + } if (!b._armed && !(self._config & CONFIG_HELD)) self._config |= CONFIG_FRAME; // Reveal order (reveal.ts): a gated slot shows its fallback — or, in a // collapsed tail, nothing — whatever its content's state; the diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index 9d34da57d..29239cdaf 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -740,7 +740,12 @@ export function laneRead(c: Computed | null, el: Signal | Computed)._statusFlags; // An errored member's lane value predates its error; the error is the answer (#4106). - if (!guess && status & STATUS_ERROR) return NOT_PENDING; + if (!guess && status & STATUS_ERROR) { + // The error still belongs to this lane. A downstream memo must select + // the same update before read() throws, as it does for a lane value. + if (c !== null && tracking && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) enterLane(l, c); + return NOT_PENDING; + } if (c === null) { if (__TEST__) shadowActual(SLOT_LV); return laneValueOf(el); diff --git a/packages/signals/tests/latest-derived-sync-error-4377.test.ts b/packages/signals/tests/latest-derived-sync-error-4377.test.ts index 14cb69913..749105fbe 100644 --- a/packages/signals/tests/latest-derived-sync-error-4377.test.ts +++ b/packages/signals/tests/latest-derived-sync-error-4377.test.ts @@ -6,11 +6,12 @@ * the error before `m` did, so ``, which waits on what threw, let it * go as settled; at the commit the leaf re-derived and reached the boundary * again, the memo's reader did not (an equal status re-notifies nobody) and - * kept `m`'s last value. With a held write the same: the leaf shows the - * error at the reveal, the derivation never did. Fixed by #4364's `prune` + * kept `m`'s last value. With a held write the leaf originally showed the + * error at the reveal, while the derivation never did. Fixed by #4364's `prune` * change (#4387): a staged error on an error boundary's source keeps the * boundary waiting, so the commit that publishes it finds the boundary still - * waiting on `m`. These pin it. + * waiting on `m`. The held cases now also pin D-41's value/error symmetry: + * latest publishes the error ahead; an ordinary read waits for the reveal. */ import { describe, expect, it } from "vitest"; import { @@ -66,7 +67,7 @@ describe("a memo over latest(m) shows m's sync error as the leaf does (#4377)", expect(s.screen()).toBe("[ERR]"); }); - it(`${shape}: a held write shows the error at the reveal`, async () => { + it(`${shape}: a held error follows the selected value publication`, async () => { const s = scene(shape); let release!: () => void; action(function* () { @@ -75,7 +76,7 @@ describe("a memo over latest(m) shows m's sync error as the leaf does (#4377)", })(); flush(); await tick(); - expect(s.screen()).toBe(0); + expect(s.screen()).toBe(shape === "plain" ? 0 : "[ERR]"); release(); await tick(); await tick(); diff --git a/packages/signals/tests/latest-uninitialized-over-latest-4362.test.ts b/packages/signals/tests/latest-uninitialized-over-latest-4362.test.ts index 19596da12..d7d13106a 100644 --- a/packages/signals/tests/latest-uninitialized-over-latest-4362.test.ts +++ b/packages/signals/tests/latest-uninitialized-over-latest-4362.test.ts @@ -307,6 +307,8 @@ describe("uninitialized async memo over latest(s) (#4362, #4363)", () => { // shape #4365 found): the pass `latest(a) + b()` runs ahead of the hold // that holds `b`, throws over b's screen value (2 + 0 + 1), and its error // is its own. The landing re-derives it on the landed world (2 + 3 + 1). + // Like a successful answer to that ahead pass, the error publishes ahead; + // the recovery then replaces it when the ordinary update lands. it.each(["memo", "effect"])( "a pass that threw over a held write re-derives at the landing (%s)", async reader => { @@ -353,7 +355,7 @@ describe("uninitialized async memo over latest(s) (#4362, #4363)", () => { setB(3); setC(2); flush(); - expect(out).toBe("title=2"); + expect(out).toBe("[E m:3]"); resolve(); for (let i = 0; i < 8; i++) await Promise.resolve(); flush(); diff --git a/packages/web/test/latest-error-publication.spec.tsx b/packages/web/test/latest-error-publication.spec.tsx new file mode 100644 index 000000000..1f0a05490 --- /dev/null +++ b/packages/web/test/latest-error-publication.spec.tsx @@ -0,0 +1,164 @@ +/** @jsxImportSource @solidjs/web */ +import { expect, test } from "vitest"; +import { action, createMemo, createSignal, Errored, latest, onCleanup, Show } from "solid-js"; +import { render } from "@solidjs/web"; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +for (const fail of [false, true]) { + for (const depth of [0, 1, 2]) { + for (const asyncAnswer of [false, true]) { + test.each([false, true])( + `latest publishes errors and recovery through ${depth} memos (async=${asyncAnswer}, error=${fail}, removal=%s)`, + async removeBranch => { + const end = Promise.withResolvers(); + const answer = Promise.withResolvers(); + const recovery = Promise.withResolvers(); + const container = document.createElement("div"); + let start!: () => Promise; + let setVersion!: (value: number) => void; + function App() { + const [version, set] = createSignal(0); + setVersion = set; + const data = createMemo(() => { + switch (version()) { + case 1: + if (asyncAnswer) return answer.promise; + if (fail) throw new Error("failed"); + return "changed"; + case 2: + if (fail) throw new Error("failed again"); + return "changed again"; + case 3: + return recovery.promise; + default: + return "ready"; + } + }); + let ahead = () => latest(data); + for (let i = 0; i < depth; i++) ahead = createMemo(ahead); + start = action(function* () { + set(1); + yield end.promise; + }); + return ( + <> +

{version()}

+ (error() as Error).message}> + + {ahead()} + + +
+ (error() as Error).message}> + {data()} + + + ); + } + const dispose = render(() => , container); + let running: Promise | undefined; + try { + await tick(); + expect(container.textContent).toBe("0readyready"); + running = start(); + await tick(); + if (asyncAnswer) { + expect(container.textContent).toBe("0readyready"); + if (fail) answer.reject(new Error("failed")); + else answer.resolve("changed"); + await tick(); + } + expect(container.textContent).toBe(fail ? "0failedready" : "0changedready"); + setVersion(2); + await tick(); + expect(container.textContent).toBe(fail ? "0failed againready" : "0changed againready"); + setVersion(3); + await tick(); + expect(container.textContent).toBe(fail ? "0failed againready" : "0changed againready"); + recovery.resolve("recovered"); + await tick(); + expect(container.textContent).toBe("0recoveredready"); + end.resolve(); + await running; + await tick(); + expect(container.textContent).toBe("3recoveredrecovered"); + } finally { + end.resolve(); + answer.resolve("ready"); + recovery.resolve("recovered"); + await running; + dispose(); + } + } + ); + } + } +} + +test("reset retries the failed source and retains the fallback until latest recovers to the same value", async () => { + const end = Promise.withResolvers(); + const recovery = Promise.withResolvers(); + const container = document.createElement("div"); + let start!: () => Promise; + let reset!: () => void; + let retry = false; + let fallbackMounts = 0; + let fallbackCleanups = 0; + const dispose = render(() => { + const [version, setVersion] = createSignal(0); + const data = createMemo(() => { + if (version() === 0) return "ready"; + if (retry) return recovery.promise; + throw new Error("failed"); + }); + start = action(function* () { + setVersion(1); + yield end.promise; + }); + return ( + <> +

{version()}

+ { + reset = retry; + fallbackMounts++; + onCleanup(() => fallbackCleanups++); + return {(error() as Error).message}; + }} + > + {latest(data)} + + + ); + }, container); + let running: Promise | undefined; + try { + await tick(); + expect(container.textContent).toBe("0ready"); + running = start(); + await tick(); + expect(container.textContent).toBe("0failed"); + const fallback = container.querySelector("b"); + retry = true; + reset(); + await tick(); + expect(container.textContent).toBe("0failed"); + expect(container.querySelector("b")).toBe(fallback); + expect([fallbackMounts, fallbackCleanups]).toEqual([1, 0]); + recovery.resolve("ready"); + await tick(); + expect(container.textContent).toBe("0ready"); + expect([fallbackMounts, fallbackCleanups]).toEqual([1, 1]); + end.resolve(); + await running; + await tick(); + expect(container.textContent).toBe("1ready"); + expect([fallbackMounts, fallbackCleanups]).toEqual([1, 1]); + } finally { + end.resolve(); + recovery.resolve("ready"); + await running; + dispose(); + } +});