Skip to content
Open
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/latest-error-publication.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -193,6 +195,11 @@ function record(b: Boundary, node: Computed<any>, source = node): void {
* decides). */
function caught(b: Boundary, node: Computed<any>, 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
Expand All @@ -205,6 +212,9 @@ function caught(b: Boundary, node: Computed<any>, error: unknown, source = node)
source = (error as { source?: Computed<any> })?.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<any>, lane, true, false);
setSignal(b._error!, caught);
reportClientError(caught, b._owner, source);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -792,6 +803,16 @@ function createBoundary<T>(
// re-arm's swap is the change's work (`arm`), until that swap commits
// (D-13, #3540).
const self = getOwner() as Computed<any>;
// 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
Expand Down
7 changes: 6 additions & 1 deletion packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,12 @@ export function laneRead(c: Computed<any> | null, el: Signal<any> | Computed<any
}
const status = (el as Computed<any>)._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);
Expand Down
11 changes: 6 additions & 5 deletions packages/signals/tests/latest-derived-sync-error-4377.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
* the error before `m` did, so `<Errored>`, 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 {
Expand Down Expand Up @@ -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* () {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading