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
7 changes: 7 additions & 0 deletions .changeset/held-recovery-keeps-shown-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

A memo that threw and then recovers under an open action now keeps its error on screen until the action commits. Before, a reader served the committed value, such as `<Errored>` content beside a `latest()` read (`"X" + latest(s) + m()`), read `undefined` in place of the error and showed `X1undefined` while the action was still open. The recovery's staging now keeps the error as the shown error until it commits (D-39, D-41). Frame and verdict readers are served that error, as an untracked read already was, and they re-derive when the action lands.

The same holds for a memo the content created on its first pass and kept in a closure (`(cached ||= createMemo(...))()`). That memo is disposed with the pass that created it. The error boundary now waits on the reader when the source that threw is disposed. Before, it waited on the dead memo, which settled at once and looped the flush.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… |
| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… |
| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… |
| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 error-staged-recovery.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… |
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… |
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … |
Expand Down
5 changes: 4 additions & 1 deletion packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,12 @@ function caught(b: Boundary, node: Computed<any>, error: unknown, source = node)
// starts (its boundary catches the pending), while the error is settled
// only when its source is neither errored nor in flight — or when the
// reader is disposed: a source no live reader holds holds nothing
// (#4373). `reset` re-runs the source.
// (#4373). A source that died (a memo its reader owned, kept past the
// pass that disposed it) settles nothing: the reader is waited on.
// `reset` re-runs the source.
error ??= node._x?._error;
source = (error as { source?: Computed<any> })?.source ?? node;
if (source._flags & REACTIVE_DISPOSED) source = node;
const caught = unwrapStatusError(error);
setSignal(b._error!, caught);
reportClientError(caught, b._owner, source);
Expand Down
10 changes: 4 additions & 6 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ export function handleAsync<T>(
// a verdict reader of the next flight would suspend instead of being
// served the screen; held, both at the derive's commit.
if (!(el._config & CONFIG_HELD)) {
el._x!._shownError = undefined;
el._x!._shownError = null;
el._config &= ~CONFIG_INPUTS_PUBLISHED;
}
if (wasUninitialized) landStatus(el, true);
Expand Down Expand Up @@ -895,7 +895,7 @@ function landStatus(el: Computed<any>, clearUninitialized: boolean = false): voi
} else clearStatus(el, clearUninitialized);
}

export function notifyStatus(el: Computed<any>, status: number, error: any): void {
export function notifyStatus(el: Computed<any>, status: number, error?: any): void {
// Wrap regular errors to track source node
if (
status === STATUS_ERROR &&
Expand All @@ -904,8 +904,7 @@ export function notifyStatus(el: Computed<any>, status: number, error: any): voi
)
error = new StatusError(el, error);

const pendingSource =
status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined;
const pendingSource = status === STATUS_PENDING && error instanceof NotReadyError && error.source;
// (CARVE 2: the lane assignment went with the engine.)

if (status === STATUS_PENDING && pendingSource) {
Expand Down Expand Up @@ -967,8 +966,7 @@ export function notifyStatus(el: Computed<any>, status: number, error: any): voi
* propagates the derive's status from each leaf to the leaf's readers
* (store/projection.ts `wakeFamily`), exactly as a memo's would reach its. */
export function propagateStatus(el: Computed<any>, status: number, error: any): void {
const pendingSource =
status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined;
const pendingSource = status === STATUS_PENDING && error instanceof NotReadyError && error.source;
forEachDependent(el, (sub, link) => {
sub._time = clock;
// A pending mark on a kept-tail link re-derives the subscriber instead of
Expand Down
42 changes: 27 additions & 15 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,9 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// Capture both error and pending status before the compute clears them.
// A conditional can drop its pending source and recover to an unchanged
// value, leaving blocked dependents outside that source’s settle walk.
const outgoingError = el._statusFlags & STATUS_ERROR ? el._x?._error : undefined;
const outgoingError = el._statusFlags & STATUS_ERROR && el._x?._error;
const wasPending = (el._statusFlags & STATUS_PENDING) !== 0;
const outgoingPendingSources = wasPending ? el._x?._pendingSources : undefined;
const outgoingPendingSources = wasPending && el._x?._pendingSources;
// Pending SOURCE-hood, captured before the compute clears status: a node
// whose own flight parked dependents self-registers in _pendingSources
// (notifyStatus, isSource). If this recompute supersedes that flight and
Expand Down Expand Up @@ -807,6 +807,10 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
el._value = value;
} else {
el._pendingValue = value;
// D-39: a staged recovery keeps the error it replaces the display
// until it commits (`commitPendingNode`), as a write over one does
// (`setMemo`).
if (outgoingError) el._x!._shownError = outgoingError;
// Born held: in the transaction from birth, so a read of it this
// tick is a read of its world (the mount-during-a-hold case), not
// only after the seam parks it.
Expand Down Expand Up @@ -846,7 +850,7 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// recoveries ride insertSubs above; a comparator throw re-errored the node
// (el._x?._error re-set), so this only runs on a genuinely clean recovery.
if (!valueChanged && !el._x?._error) {
if (outgoingError !== undefined) settleErroredDependents(el, outgoingError);
if (outgoingError) settleErroredDependents(el, outgoingError);
// Self-registration (this node's own superseded flight) is the #3181
// sweep's business below — retiring it here too would walk twice.
if (outgoingPendingSources)
Expand Down Expand Up @@ -1116,7 +1120,7 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension {
_q: 0,
_lane: NOT_PENDING,
_marks: 0,
_shownError: undefined
_shownError: null
});
}

Expand Down Expand Up @@ -2065,11 +2069,11 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
} else throw (owner as Computed<any>)._x?._error;
}

if (committed) return el._value as T;
// D-41: an untracked read reads the screen, and a shown error is on it
// until the answer that replaces it commits.
if (!c && el._x?._shownError) throw el._x._shownError;
const value = serve(el, c as Computed<any> | null) as T;
// D-41: an untracked read reads the screen, and so does a frame reader
// served the committed value — `serve` hands it that value as it does an
// untracked read — a shown error included (`throwShown`).
if (committed || !c) throwShown(el);
const value = serve(el, committed ? null : (c as Computed<any> | null)) as T;
if (
!c &&
typeof computed._fn === "function" &&
Expand All @@ -2089,6 +2093,13 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
return value;
}

/** D-41: a reader served the screen — an untracked read, a frame reader
* served the committed value, a verdict reader (`serve`) — reads a shown
* error there until the answer that replaces it commits. */
function throwShown(el: Signal<any> | Computed<any>): undefined {
if (el._x?._shownError) throw el._x._shownError;
}

/**
* Rule 1, the one slow implementation (DESIGN-CONSOLIDATION move 3b, step
* 6c): the value a reader `c` (null = untracked, no pass) is served from
Expand All @@ -2115,12 +2126,13 @@ export function serve(el: Signal<any> | Computed<any>, c: Computed<any> | null):
markLateLinker(c!);
return u;
}
if (readerSeesCommitted(el, c)) return el._value;
if (
observeFlightHook &&
c!._config & CONFIG_VERDICT &&
!((el as Computed<any>)._statusFlags & STATUS_UNINITIALIZED) &&
stagedScreen(c!)
readerSeesCommitted(el, c) ||
(observeFlightHook &&
c!._config & CONFIG_VERDICT &&
!((el as Computed<any>)._statusFlags & STATUS_UNINITIALIZED) &&
stagedScreen(c!) &&
!throwShown(el))
)
return el._value;
stagedRead(c!, el);
Expand Down Expand Up @@ -2361,7 +2373,7 @@ export function setMemo<T>(el: Computed<T>, v: T | ((prev: T) => T)): T {
// The error stays the display until the write commits (D-41).
if (el._statusFlags & STATUS_ERROR) {
el._x!._shownError = el._x!._error;
notifyStatus(el, STATUS_NONE, undefined);
notifyStatus(el, STATUS_NONE);
}
v = setSignal(el, v);
// A write replaces an error a pass staged (D-41): it is a value again.
Expand Down
12 changes: 9 additions & 3 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,8 +1170,14 @@ export function reruns(u: Transaction): unknown {
const r = u._reruns[i];
// An errored reader's display is its error (D-39): the landing gives it
// nothing to re-derive, and a re-run would be a read that retries the
// errored source (#3945).
if (r._flags & REACTIVE_DISPOSED || r._statusFlags & STATUS_ERROR) continue;
// errored source (#3945) — unless that source is errored no longer: the
// reader was served a shown error the landing replaces (`throwShown`).
if (
r._flags & REACTIVE_DISPOSED ||
(r._statusFlags & STATUS_ERROR &&
((r._x?._error as any)?.source?._statusFlags ?? STATUS_ERROR) & STATUS_ERROR)
)
continue;
if (r._flags & REACTIVE_FRAME_READ || r._config & CONFIG_VERDICT) {
frame ||= (r as any)._type;
r._flags &= ~REACTIVE_FRAME_READ;
Expand Down Expand Up @@ -1351,7 +1357,7 @@ export function commitPendingNode(n: Signal<any>): void {
const inFlight = n._pendingValue === NOT_PENDING && (c._statusFlags! & STATUS_PENDING) !== 0;
const err = n._config & CONFIG_STAGED_ERROR;
// An answer commits: the error it replaces leaves the display (D-41).
if (!inFlight && c._x) c._x._shownError = undefined;
if (!inFlight && c._x) c._x._shownError = null;
n._config = inFlight
? (n._config & ~(CONFIG_HELD | CONFIG_HELD_FRAME)) | CONFIG_INPUTS_PUBLISHED
: n._config &
Expand Down
141 changes: 141 additions & 0 deletions packages/signals/tests/error-staged-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* D-39 / D-41: a sync memo that errored and recovers in a held pass keeps
* its shown error until that pass commits (F-7). A reader served the screen
* — a frame or verdict reader beside a display-ahead `latest()` (D-25, A10),
* an untracked read — reads the error, never the value behind it, and
* re-derives at the landing.
*
* `m` throws for the committed `s` and recovers for the action's write; the
* action stays open, so the recovery is held with it. `t` is written
* mainline meanwhile: the view re-runs over the held `m` (a frame read).
*/
import { expect, it } from "vitest";
import {
action,
createErrorBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
latest
} from "../src/index.js";

const tick = async () => {
await new Promise(r => setTimeout(r, 0));
flush();
};

it.each([false, true])(
"a held recovery of a sync memo keeps the shown error for a frame reader (memo between: %s)",
async between => {
let release!: () => void;
const gate = new Promise<void>(r => (release = r));
const [s, setS] = createSignal(0);
const [t, setT] = createSignal("X");
const views: string[] = [];
let screen: unknown;
let m!: () => number;
const dispose = createRoot(d => {
m = createMemo(() => {
if (s() === 0) throw new Error("boom");
return s();
});
const fn = () => {
const v = t() + latest(s) + m();
views.push(v);
return v;
};
const view = between ? createMemo(fn) : fn;
const errored = createErrorBoundary(view, e => `E:${(e() as Error).message}`);
createRenderEffect(errored, v => void (screen = v));
return d;
});
const untracked = () => {
try {
return m();
} catch (e) {
return ((e as Error).cause as Error)?.message ?? (e as Error).message;
}
};
try {
flush();
expect(screen).toBe("E:boom");

const run = action(function* () {
setS(1);
yield gate;
});
run();
flush();
// The recovery is held with the action: the error stays the display.
expect(screen).toBe("E:boom");
expect(untracked()).toBe("boom");
expect(latest(m)).toBe(1);

// A mainline write re-runs the view over the held recovery.
setT("Y");
flush();
expect(screen).toBe("E:boom");
expect(untracked()).toBe("boom");

release();
await tick();
await tick();
expect(screen).toBe("Y11");
expect(untracked()).toBe(1);
// No pass paired the held `latest` with a value the screen never had.
expect(views).toEqual(["Y11"]);
} finally {
release();
dispose();
flush();
}
}
);

// The memo is the verdict reader's own child, created on its first pass and
// kept by closure past the pass that disposes it. The shown error it throws
// names a source that died: the boundary waits on the reader (the tree), not
// on the dead source — which would settle at once, redraw, re-catch and
// loop the flush (#4396 review).
it.each([false, true])(
"a held recovery of a memo the reader owned stays live (memo between: %s)",
async between => {
let release!: () => void;
const gate = new Promise<void>(r => (release = r));
const [s, setS] = createSignal(0);
const screens: unknown[] = [];
let kept: (() => number) | undefined;
const dispose = createRoot(d => {
const fn = () =>
"X" +
latest(s) +
(kept ||= createMemo(() => {
if (s() === 0) throw new Error("boom");
return s();
}))();
const view = between ? createMemo(fn) : fn;
const errored = createErrorBoundary(view, e => `E:${(e() as Error).message}`);
createRenderEffect(errored, v => void screens.push(v));
return d;
});
try {
flush();
action(function* () {
setS(1);
yield gate;
})();
flush();
expect(screens).toEqual(["E:boom"]);
release();
await tick();
await tick();
expect(screens).toEqual(["E:boom", "X11"]);
} finally {
release();
dispose();
flush();
}
}
);
Loading
Loading