From d589b55091736b9fabf7545981901c4388d26fc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:13:41 +0000 Subject: [PATCH 1/7] proto(signals): stage errors like values, publish at the commit (D-40) Prototype for the proposed D-40: an error a held pass finds is staged in _pendingValue (CONFIG_STAGED_ERROR), propagated to dependents as a staging of their own, and published by commitPendingNode when the frame lands. A held recovery keeps the shown error until its landing; a re-ask of the source whose error a node shows re-runs it on the settle. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr --- packages/signals/src/core/async.ts | 101 +++++++- packages/signals/src/core/constants.ts | 3 + packages/signals/src/core/core.ts | 26 ++- packages/signals/src/core/scheduler.ts | 19 +- packages/signals/src/core/verdict.ts | 14 +- .../signals/tests/error-is-an-answer.test.ts | 218 ++++++++++++++++++ 6 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 packages/signals/tests/error-is-an-answer.test.ts diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 9ba0eb1870..eb21758d6a 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -4,6 +4,8 @@ import { CONFIG_GUESS, CONFIG_OVERRIDE, CONFIG_HELD, + CONFIG_STAGED, + CONFIG_STAGED_ERROR, CONFIG_SYNC, CONFIG_VERDICT, EFFECT_TRACKED, @@ -11,6 +13,7 @@ import { NOT_PENDING, REACTIVE_DIRTY, REACTIVE_DISPOSED, + REACTIVE_STAGED_READ, REACTIVE_ZOMBIE, STATUS_ERROR, STATUS_PENDING, @@ -29,6 +32,7 @@ import { flush, globalQueue, GlobalQueue, + holdNode, joinFuture, MAINLINE_QUESTION, passLane, @@ -167,6 +171,54 @@ export function releaseSettledDependents(el: Computed): void { // (releaseSettledDependents shape): identity holders can sit below an // intermediate whose own error state has since been scrubbed or replaced // (e.g. an error boundary's tree node). +/** D-40 (proposed): an error a pass of a held node found is an answer of + * that frame — it ends the wait like a value and shows when a value would. + * Staged like one: `_pendingValue` carries it (CONFIG_STAGED_ERROR), readers + * in the hold derive from it (`serve` throws it), and the landing publishes + * it (`commitPendingNode` → `notifyStatus`). Not for a lane pass, nor a node + * with no shown value (born held — its error has no frame to wait behind): + * those publish now, as before. Returns whether the error was staged. */ +export function stageError(el: Computed, error: any, inPass?: boolean): boolean { + // Held, or — for a pass's own throw — derived from a staging of this flush + // (REACTIVE_STAGED_READ is this pass's; a landing after the pass cannot + // read it, the flush it read a staging of has committed or parked since). + if ( + !(el._config & CONFIG_HELD || (inPass && el._flags & REACTIVE_STAGED_READ)) || + el._statusFlags & STATUS_UNINITIALIZED || + passLane !== null + ) + return false; + if (!(error instanceof StatusError)) error = new StatusError(el, error); + const wasSource = !!el._x?._pendingSources?.has(el); + stageErrorOn(el, error); + // Dependents inherit it as a staging of their own (one identity down the + // tree, as a published error propagates) and commit it with this node. + propagateStatus(el, STATUS_ERROR, error, undefined, true); + if (wasSource) settlePendingSource(el); + return true; +} + +/** The staging itself: the node answered (an error ends the wait — no + * longer pending, nor blocked), a shown error stays its display until the + * commit, and `_pendingValue` carries the new one. */ +function stageErrorOn(el: Computed, error: any): void { + clearPendingSources(el); + if (el._x !== null) el._x._blocked = false; + if (!(el._statusFlags &= STATUS_ERROR)) setPendingError(el); + el._pendingValue = error; + el._config |= CONFIG_STAGED | CONFIG_STAGED_ERROR; +} + +/** The commit of a staged error (`stageError`): the node's own status — no + * propagation, its dependents staged the same error and commit their own. */ +export function publishError(el: Computed, error: any): void { + clearPendingSources(el); + el._statusFlags = STATUS_ERROR; + ext(el)._error = error; + const notify = statusNotifierOf(el); + if (notify) notify.call(el); +} + export function settleErroredDependents(el: Computed, error: any): void { let scheduled = false; const visited = new Set>(); @@ -428,6 +480,14 @@ export function handleAsync( el._time = clock; return; } + // (A derived store's readers learn an error through its family, not the + // derive's dependents: it publishes now — residual, prototype.) + if (!stillPending && !setter && stageError(el, error)) { + el._time = clock; + onError?.(error, false); + schedule(); + return; + } notifyStatus(el, stillPending ? STATUS_PENDING : STATUS_ERROR, error); // A NotReady rejection is a landing into another pending source. The // rejected flight will never settle its self entry, so transfer ownership @@ -448,7 +508,13 @@ export function handleAsync( // with the new value, creating a fresh Promise that supersedes this one. if (el._flags & REACTIVE_DIRTY) return; const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED); - landStatus(el); + // D-40 (proposed): a held node's shown error stays shown until the + // landing commits the recovery (`commitPendingNode` clears it there); an + // unheld one clears now. Either way the dependents holding the error + // re-run, a same-value recovery included (#3886). + const published = el._statusFlags & STATUS_ERROR ? el._x!._error : undefined; + const keep = published !== undefined && (el._config & CONFIG_HELD) !== 0 && !wasUninitialized; + if (!keep) landStatus(el); // Attribution hook: lets the engine snapshot state before the landing // branches, so it can tell whether the plain path's setSignal committed a // change (and only then classify it as an async landing). @@ -483,6 +549,8 @@ export function handleAsync( // pre-commit failure here, and there is no user callsite to throw to. notifyStatus(el, STATUS_ERROR, e); } + if (!keep && published !== undefined && el._pendingValue === NOT_PENDING) + settleErroredDependents(el, published); // Attribution hook: this path landed through setSignal, whose write // hook already saw any committed change — direct=false lets the engine // reclassify that write as an async landing iff it actually committed. @@ -783,7 +851,7 @@ export function clearStatus(el: Computed, clearUninitialized: boolean = fal * reader thrown NotReady(self) would park on a retired entry. Companions * keep their verdict (pending before and after; the write re-syncs them). */ -function landStatus(el: Computed, clearUninitialized: boolean = false): void { +export function landStatus(el: Computed, clearUninitialized: boolean = false): void { const sources = el._x?._pendingSources; // (The full clear below drops the set whether or not self was retired first.) if (sources && (sources.delete(el), sources.size)) { @@ -797,7 +865,8 @@ export function notifyStatus( el: Computed, status: number, error: any, - blockStatus?: boolean + blockStatus?: boolean, + staged?: boolean ): void { // Wrap regular errors to track source node if ( @@ -818,6 +887,10 @@ export function notifyStatus( // Preserve the current source on this propagation so readers park on // the distinct pending source. setPendingError(el, pendingSource, error); + } else if (staged) { + // D-40 (proposed): a dependent of a staged error stages it too; its + // status is the commit's (`publishError`), its display unchanged. + stageErrorOn(el, error); } else { clearPendingSources(el); el._statusFlags = @@ -841,6 +914,7 @@ export function notifyStatus( const elNotify = statusNotifierOf(el); if (elNotify) { + if (staged) return; if (blockStatus && status === STATUS_PENDING) { return; } @@ -851,7 +925,7 @@ export function notifyStatus( } return; } - propagateStatus(el, status, error, downstreamBlockStatus); + propagateStatus(el, status, error, downstreamBlockStatus, staged); } /** The dependents' half of `notifyStatus`: `el`'s status reaches each @@ -863,7 +937,8 @@ export function propagateStatus( el: Computed, status: number, error: any, - downstreamBlockStatus?: boolean + downstreamBlockStatus?: boolean, + staged?: boolean ): void { const pendingSource = status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined; @@ -939,7 +1014,21 @@ export function propagateStatus( joinFuture(txOf(sub)); } } - notifyStatus(sub, status, error, downstreamBlockStatus); + // A re-ask of the source whose error this node shows: whatever its + // settle lands on is a new answer here (the error was the display, + // D-40) — re-run on it (`settlePendingSource`), a landing equal to the + // last good value included (#3886). + if ( + status === STATUS_PENDING && + sub._statusFlags & STATUS_ERROR && + (sub._x!._error as StatusError).source === pendingSource + ) + ext(sub)._blocked = true; + // A staged error from a held node: the dependent's staging is held + // with it (committed at the landing, as the park would list it). + if (staged && el._config & CONFIG_HELD && !(sub._config & (CONFIG_HELD | CONFIG_OVERRIDE))) + holdNode(sub, txOf(el)); + notifyStatus(sub, status, error, downstreamBlockStatus, staged); } }); } diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 8605118995..55de77a4b2 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -123,6 +123,9 @@ export const CONFIG_SLOT_NODE = 1 << 12; * leaf twin of REACTIVE_MANUAL_WRITE). Set by the user setter, cleared by * the derive's own write to the key. */ export const CONFIG_MANUAL_WRITE = 1 << 18; +/** D-40 (proposed): the staged value in `_pendingValue` is an error — an + * answer of the held frame, published at its landing like a value. */ +export const CONFIG_STAGED_ERROR = 1 << 19; /** Lanes — the node carries a written guess (`optimisticWrite`): its own * source recomputing it, or a plain write landing on it, is the truth * (`supersede`). A lane's derived staging is not. */ diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index bbb0b1e728..103a8dcd06 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -5,7 +5,8 @@ import { parkLoadingWindow, releaseFlightTeardown, settleErroredDependents, - settlePendingSource + settlePendingSource, + stageError } from "./async.js"; import { $REFRESH, @@ -29,6 +30,7 @@ import { CONFIG_STAGED, CONFIG_HELD_FRAME, CAUGHT_REARMED, + CONFIG_STAGED_ERROR, CONFIG_SYNC, CONFIG_TRANSPARENT, defaultContext, @@ -365,6 +367,8 @@ export function recompute(el: Computed, create: boolean = false): void { // settlePendingSource walk never runs for a landing that was preempted // (#3181). const wasPendingSource = el._x?._pendingSources?.has(el); + // D-40 (proposed): this pass's error is staged, not published (`stageError`). + let stagedErr = false; // A19 exc. 2: a `refresh()` re-asks the question already answered — quiet // for the verdict — unless the question in flight is a new one, which the // re-ask does not launder (a poll during a quiet confirm stays quiet). @@ -442,6 +446,8 @@ export function recompute(el: Computed, create: boolean = false): void { // node is currently errored the error stays the answer until this // retry can actually run. parkLoadingWindow(el, e as NotReadyError); + } else if (!notReady && stageError(el, e, true)) { + stagedErr = true; } else { if (notReady) ext(el)._blocked = true; notifyStatus(el, notReady ? STATUS_PENDING : STATUS_ERROR, e); @@ -527,13 +533,13 @@ export function recompute(el: Computed, create: boolean = false): void { let lane = passLane; // Listed before its staging, a pending pass included (the lane's own // flight is the lane's); false: the pass left the lane (lanes.ts). - const errored = !!el._x?._error; + const errored = !!el._x?._error || stagedErr; if (lane !== null) { if (GlobalQueue._laneStage!(el, lane, create, errored)) setPassLane(lane); else lane = null; } - if (!el._x?._error) { + if (!errored) { // Observe-tier fan-in (HUGE_FAN_IN): the validated prefix [_deps.._depsTail] // IS this pass's distinct sources — count it here rather than per link. A // begin/end bracket around the pass plus a per-link increment measured @@ -1827,7 +1833,13 @@ export function read(el: Signal | Computed): T { } // An errored derive throws for every late reader instead of silently // serving node values (memo parity, #2897 ruling). - if ((owner as Computed)._fn && (owner as Computed)._statusFlags & STATUS_ERROR) { + // D-40 (proposed): a reader in the hold of a recovery staged over the + // shown error derives from the recovery (`serve`), not the error. + if ( + (owner as Computed)._fn && + (owner as Computed)._statusFlags & STATUS_ERROR && + readerSeesCommitted(el, c as Computed | null) + ) { // Only a genuine reactive re-read may retry an errored async source: // - tracking: owned/tracked scope only (never events / `untrack` / effect side-effect phase) // - owner._time < clock: only on a later cycle than the one the error was found @@ -1912,6 +1924,8 @@ export function serve(el: Signal | Computed, c: Computed | null): ) return el._value; stagedRead(c!, el); + // D-40 (proposed): the staged answer is an error (`stageError`). + if (el._config & CONFIG_STAGED_ERROR) throw el._pendingValue; return el._pendingValue; } @@ -2020,8 +2034,10 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T if (el._config & CONFIG_HELD) joinFuture(txOf(el)); // Uninitialized check first: the first commit has no previous value, so the // user comparator must not run against `undefined` (matches recompute). + // D-40 (proposed): a write over a shown error is a change whatever the + // last good value was — the error leaves (`commitPendingNode`). const valueChanged = - !!((el as Computed)._statusFlags & STATUS_UNINITIALIZED) || + !!((el as Computed)._statusFlags & (STATUS_UNINITIALIZED | STATUS_ERROR)) || !el._equals || !el._equals(currentValue, v); if (!valueChanged) return v; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 5357c5d3cd..f9e589c30a 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -7,6 +7,7 @@ import { CONFIG_OVERRIDE, CONFIG_REDERIVE, CONFIG_STAGED, + CONFIG_STAGED_ERROR, CONFIG_VERDICT, EFFECT_RENDER, EFFECT_TRACKED, @@ -28,6 +29,7 @@ import { STATUS_UNINITIALIZED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; +import { landStatus, publishError, settleErroredDependents } from "./async.js"; import { ext, markUnflushedStaged, recompute, resyncUnflushedCompanions } from "./core.js"; import { DEV, emitDiagnostic, GRAPH_SIZE_WARN_AT, noteFanOut, reportDiagnostic } from "./dev.js"; import { sweepDormant, trimStaleDeps } from "./graph.js"; @@ -1172,10 +1174,25 @@ export function commitPendingNode(n: Signal): void { // commit beneath a flight publishes its inputs (A15 reveal corollary, // #3305): a reveal of it must observe, not show the pre-flight value. const inFlight = n._pendingValue === NOT_PENDING && (c._statusFlags! & STATUS_PENDING) !== 0; + const held = n._config & CONFIG_HELD; n._config = inFlight ? (n._config & ~(CONFIG_HELD | CONFIG_HELD_FRAME)) | CONFIG_INPUTS_PUBLISHED : n._config & ~(CONFIG_HELD | CONFIG_HELD_FRAME | CONFIG_STAGED | CONFIG_INPUTS_PUBLISHED); - if (n._pendingValue !== NOT_PENDING) { + // D-40 (proposed): the staged answer is an error — published now, when a + // value would show (`stageError`). + if (n._config & CONFIG_STAGED_ERROR) { + const e = n._pendingValue; + n._pendingValue = NOT_PENDING; + n._config &= ~CONFIG_STAGED_ERROR; + publishError(c as Computed, e); + } else if (n._pendingValue !== NOT_PENDING) { + // A held recovery: the shown error leaves with the value that replaces + // it, and the dependents holding it re-run (`asyncWrite` kept it). + if (held && c._statusFlags! & STATUS_ERROR) { + const e = c._x!._error; + landStatus(c as Computed); + settleErroredDependents(c as Computed, e); + } n._value = n._pendingValue as any; n._pendingValue = NOT_PENDING; // A node born staged (recompute) initializes at this commit. diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 793b69d666..1c6b78b38d 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -15,6 +15,7 @@ import { CONFIG_HELD, CONFIG_INPUTS_PUBLISHED, CONFIG_OVERRIDE, + CONFIG_STAGED_ERROR, CONFIG_VERDICT, EFFECT_RENDER, NOT_PENDING, @@ -210,7 +211,9 @@ function heldNotFinal(owner: Computed): boolean { !latestActive && !owner._x!._reask && !(owner._statusFlags & STATUS_UNINITIALIZED) && - (!owner._equals || !owner._equals(owner._value as any, owner._pendingValue as any)) + ((owner._statusFlags & STATUS_ERROR) !== 0 || + !owner._equals || + !owner._equals(owner._value as any, owner._pendingValue as any)) ); } @@ -285,12 +288,17 @@ export function isPending(fn: () => any): boolean { } } +/** The staging, or the staged error thrown (D-40, proposed). */ +function staged(el: Signal | Computed): unknown { + if (el._config & CONFIG_STAGED_ERROR) throw el._pendingValue; + return el._pendingValue; +} /** `latest()` of a held node: the flushed staging — the proposal — and, for * a tracked reader of a rewrite not yet flushed, the late-linker re-run in * the flush that carries it. */ function heldLatest(el: Signal | Computed, c: Computed | null): unknown { const u = unflushedValue(el); - if (u === NOT_PENDING) return el._pendingValue; + if (u === NOT_PENDING) return staged(el); if (c !== null) markLateLinker(c); return u; } @@ -420,7 +428,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): // A28: an unflushed write is not what `latest` serves; a tracked // reader derived from the flushed world runs again in the carrying // flush. - if (globalQueue._running) return el._pendingValue; + if (globalQueue._running) return staged(el); if (c !== null) markLateLinker(c); } return el._value; diff --git a/packages/signals/tests/error-is-an-answer.test.ts b/packages/signals/tests/error-is-an-answer.test.ts new file mode 100644 index 0000000000..7bf399ca46 --- /dev/null +++ b/packages/signals/tests/error-is-an-answer.test.ts @@ -0,0 +1,218 @@ +/** + * D-40 (proposed): an error is an answer — it ends the wait like a value and + * shows when a value would. S-41, S-42. Shapes from #3886's audit (GabbeV). + * + * `data` and `slow` both re-ask on `count`; a scheduled render effect over + * `slow` holds the frame (F-2). A rejection of `data` settles it — the hold + * never waits on an error — but must not SHOW until `slow` lands: the + * effect's error arm, the untracked read, the boundary and `isPending` all + * keep the previous frame; `latest` is the one early view (F-6). Recovery is + * the mirror (D-39), and recovering to the last good value is still a new + * answer. + * + * Pinned `it.fails` while D-40 is proposed, not ruled (OPEN-QUESTIONS Q-6). + * The no-hold case at the end is a plain bug, pinned as a failing `it`. + */ +import { expect, it } from "vitest"; +import { + createEffect, + createErrorBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +const tick = async () => { + await new Promise(r => setTimeout(r, 0)); + flush(); +}; + +function scene(boundary: boolean) { + let request = deferred(); + let sibling = deferred(); + const [count, setCount] = createSignal(1); + const screen = { count: 0, data: undefined as unknown, slow: 0 }; + const log: unknown[] = []; + let data!: () => number; + const dispose = createRoot(d => { + data = createMemo(() => { + count(); + return request.promise; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(count, v => void (screen.count = v), { schedule: true }); + // With a boundary the error is content (F-4): the screen shows its + // fallback. Without one the user effect's arm is the only consumer. + if (boundary) { + const errored = createErrorBoundary(data, e => `Error ${(e() as Error).message}`); + createRenderEffect(errored, v => void (screen.data = v), { schedule: true }); + } else screen.data = "no boundary"; + createRenderEffect(slow, v => void (screen.slow = v), { schedule: true }); + createEffect(data, { effect: v => void log.push(v), error: e => void log.push(e) }); + return d; + }); + const ask = (n: number) => { + request = deferred(); + sibling = deferred(); + setCount(n); + flush(); + }; + const untracked = () => { + try { + return data(); + } catch (e) { + // The engine throws its status wrapper; the user's error is its cause. + return (e as Error).cause ?? e; + } + }; + return { + screen, + log, + data, + ask, + untracked, + answer: (v: number) => request.resolve(v), + fail: (e: unknown) => request.reject(e), + land: (v: number) => sibling.resolve(v), + dispose + }; +} + +it.each([true, false])( + "S-41: a held rejection reveals with its frame (boundary: %s)", + async boundary => { + const s = scene(boundary); + const shown = boundary ? "Error 2" : "no boundary"; + const boom = new Error("2"); + try { + flush(); + s.answer(1); + s.land(1); + await tick(); + expect(s.screen).toEqual({ count: 1, data: boundary ? 1 : "no boundary", slow: 1 }); + expect(s.log).toEqual([1]); + + s.ask(2); + expect(s.screen).toEqual({ count: 1, data: boundary ? 1 : "no boundary", slow: 1 }); + expect(isPending(s.data)).toBe(true); + expect(latest(s.data)).toBe(1); + + // Settled, not shown. + s.fail(boom); + await tick(); + expect(s.screen).toEqual({ count: 1, data: boundary ? 1 : "no boundary", slow: 1 }); + expect(s.log).toEqual([1]); + expect(s.untracked()).toBe(1); + expect(isPending(s.data)).toBe(true); + expect(() => latest(s.data)).toThrow("2"); + + // One frame: input, error fallback and sibling. + s.land(2); + await tick(); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.log).toEqual([1, boom]); + expect(s.untracked()).toBe(boom); + expect(isPending(s.data)).toBe(false); + } finally { + s.dispose(); + flush(); + } + } +); + +it.each([true, false])( + "S-42: a held recovery keeps the shown error; the last good value is a new answer (boundary: %s)", + async boundary => { + const s = scene(boundary); + const shown = boundary ? "Error 2" : "no boundary"; + const recovered = boundary ? 1 : "no boundary"; + const boom = new Error("2"); + try { + flush(); + s.answer(1); + s.land(1); + await tick(); + s.ask(2); + s.fail(boom); + s.land(2); + await tick(); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.log).toEqual([1, boom]); + + // The error stays the display through the retry (D-39). + s.ask(3); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.untracked()).toBe(boom); + expect(isPending(s.data)).toBe(true); + + // Settled to the last good value, not shown. + s.answer(1); + await tick(); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.untracked()).toBe(boom); + expect(s.log).toEqual([1, boom]); + expect(isPending(s.data)).toBe(true); + expect(latest(s.data)).toBe(1); + + // `1` equals the last good value and is still a new answer. + s.land(3); + await tick(); + expect(s.screen).toEqual({ count: 3, data: recovered, slow: 3 }); + expect(s.untracked()).toBe(1); + expect(s.log).toEqual([1, boom, 1]); + expect(isPending(s.data)).toBe(false); + } finally { + s.dispose(); + flush(); + } + } +); + +// No hold at all: the same recovery is a plain bug, not a rule question. The +// landing equal to the last good value passes the equality gate while the +// node is errored, so the effect never hears the recovery (#3886). Pinned +// `it.fails` until its own fix lands. +it("recovering to the last good value after an async rejection reaches the effect", async () => { + const boom = new Error("failed"); + const [count, setCount] = createSignal(0); + const log: unknown[] = []; + let data!: () => number; + const dispose = createRoot(d => { + data = createMemo(() => (count() === 1 ? Promise.reject(boom) : Promise.resolve(1))); + createEffect(data, { effect: v => void log.push(v), error: e => void log.push(e) }); + return d; + }); + try { + flush(); + await tick(); + setCount(1); + flush(); + await tick(); + expect(log).toEqual([1, boom]); + setCount(2); + flush(); + await tick(); + expect(data()).toBe(1); + expect(isPending(data)).toBe(false); + expect(log).toEqual([1, boom, 1]); + } finally { + dispose(); + flush(); + } +}); From 03f664f0d19208376cb42f9083cce0d4a8ee2dec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:47:40 +0000 Subject: [PATCH 2/7] =?UTF-8?q?docs(signals):=20D-40,=20an=20error=20is=20?= =?UTF-8?q?an=20answer=20=E2=80=94=20proposed=20rule,=20scenarios=20and=20?= =?UTF-8?q?pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An error settles the async its node asked for, so a hold never waits on it; but settled is not shown. A value landing on a held node is staged until the frame reveals, an error publishes at once. F-1 and F-2 already decide this; D-40 states it as a narrowing of F-12, S-41/S-42 are the screens, Q-6 puts the two columns to the maintainer, Q-7 the loadingValue contract question #3886 raised beside it. Pinned it.fails. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr --- packages/signals/docs/OPEN-QUESTIONS.md | 37 +++++++++++++++++++- packages/signals/docs/RULES-DERIVED.md | 16 +++++++++ packages/signals/docs/RULES-FUNDAMENTALS.md | 4 +-- packages/signals/docs/SCENARIOS.md | 38 ++++++++++++++++++++- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/signals/docs/OPEN-QUESTIONS.md b/packages/signals/docs/OPEN-QUESTIONS.md index 03f1240eb0..3d64593297 100644 --- a/packages/signals/docs/OPEN-QUESTIONS.md +++ b/packages/signals/docs/OPEN-QUESTIONS.md @@ -2,7 +2,42 @@ **Status: one batch, 2026-10-07.** These are the places where two sources genuinely disagree, or where a ruling was read two ways within a day. The worker did not decide them. Each is laid out as a sequence with the candidate screens side by side; the answer is a column letter. Where the record already leans one way, that is said, with the evidence — but the fundamental is fixed by your answer here, not by the record. -Three sections were drafted. **Q** stays while a ruled screen is still written out here. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, are now carried by the fundamentals, the derived rules and the scenarios, and are deleted. **U** — ruled, not built (`it.fails` pins), confirm the ruling stands and say whether it is for 2.0. This file goes when U is answered. +Three sections were drafted. **Q** stays while a ruled screen is still written out here; Q-6 and Q-7 (2026-10-12, from #3886) are open. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, are now carried by the fundamentals, the derived rules and the scenarios, and are deleted. **U** — ruled, not built (`it.fails` pins), confirm the ruling stands and say whether it is for 2.0. This file goes when U is answered. + +--- + +## Q. Proposed, not ruled — the answer is a column letter + +### Q-6. Does an error wait for its frame the way a value does? (D-40, from #3886) + +The sequence is S-41: `count` 1 → 2; `data(2)` rejects while the sibling `slow(2)` is still in flight; an `` around `data`, a `createEffect(data, { effect, error })`, and `

{count()}

` beside it. + +| | **A — today** | **B — D-40** | +| -------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| screen after `data(2)` rejects, `slow(2)` up | `1 \| Error 2 \| 1` | `1 \| 1 \| 1` | +| the error arm | runs now | runs when `slow(2)` lands, in the frame that shows `2` | +| `data()` untracked, meanwhile | throws `Error 2` | `1` (the screen) | +| `isPending(data)` / `latest(data)`, meanwhile | `false` / throws `Error 2` | `true` / throws `Error 2` | +| `resolve(data)` / `until` / `refresh` called in the hold | reject now | reject at the landing (D-31: delivered by the hold they are called in) | +| what the hold waits for | `slow(2)` | `slow(2)` — unchanged in both columns | +| recovery to the last good value (S-42 step 6) | the arm never runs; the screen shows `1` with the arm still on `Error 2` | the arm runs with `1` | + +**Where the record leans.** B. F-1 (the fallback is content, F-4, and `Error 2` beside `slow`'s `1` is a value beside a stale derivation of its input); F-2 (a change that needs async shows nothing of itself until that async has landed; the error is part of the change); D-39's reading of the mirror. A was never ruled: the engine publishes an error straight into the node's working status, which is also what reads and effects see, so the error has one state where a value has two (staged, committed). "An error is the resolved future" is true of what the hold waits on, and both columns agree there. + +**Not in the question.** The standalone bugs #3886 found beside this, each its own fix with no rule: an async recovery to the last good value never notifies the effect even with nothing held; a write to a failed writable memo does not clear its error; a thrown value whose `toString`/`message` throws reaches the arm as the formatter's error; a failing async comparator does not release unobserved lazy readers; snapshot scopes do not capture a failure. + +### Q-7. `loadingValue` after a shown failure: does a pending retry serve the seed again? (from #3886) + +`createMemo(() => fetch(id()), { loadingValue: 42 })`; the first request rejects and the error shows; then `setId(2)` re-asks. + +| | **A — today** (`loading-value.test.ts:268`, #2989) | **B — #3886's contract change** | +| ----------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------- | +| screen while the retry is in flight | `42` — the seed serves again; `isPending` is `false` | the error stays; `isPending` is `true` | +| the seed as memo `prev` | `42` | `42` (unchanged: the seed stays the last successful `prev`) | + +**Where the record leans.** B is D-39's wording applied literally ("the error stays until a new answer replaces it; a pending is not a value"), and D-30 says a declared first paint is _loading_, not pending, which is A's ground: the seed is the first paint until a first answer. A revealed failure is a first answer, which favors B. A is pinned twice today and was ruled for #2989 before D-39 existed. Not needed for Q-6; decide on its own. + +**Answer format that is enough:** `Q-6 B, Q-7 A` — or the line you prefer. --- diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index cba598d732..c2a147223e 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -334,6 +334,22 @@ An `` flipping to its fallback is a boundary going back to its fallback --- +### D-40. An error is an answer: it ends the wait like a value, and it shows when a value would + +**From:** F-2, F-1, F-12, D-39. **Scenarios:** S-41, S-42. **Proposed 2026-10-12, not ruled** (Q-6). + +A rejection, a compute-phase throw or a comparator throw is the answer to the question its node asked. As an answer it _settles_ the async: a hold never waits on an errored flight, and nothing here changes what a hold waits for. But settled is not shown. A value that lands on a held node is staged until the frame reveals (F-2); an error that lands on a held node is staged the same way, because the error fallback is content (F-4) and showing it beside the previous frame's other content is the torn screen F-1 forbids. Until the reveal: the screen keeps what it showed, an ordinary outside read serves the committed value (it reads the screen, D-29, D-34), `createEffect`'s error arm does not run, `` does not flip, `resolve`/`until`/`refresh` do not reject (D-31), `isPending` of it is `true`, and `latest` of it throws the proposed error — the one way to see the answer early (F-6). At the reveal the error and everything that waited with it show as one frame: the arm runs, the boundary shows its fallback, the outside read throws, `isPending` is `false`. + +Recovery is the mirror (D-39): a shown error stays the display until the frame that carries its replacement reveals — through a pending retry, and through a held recovery, success or a different failure. A recovery to a value equal to the last good one is still a change of answer: payload equality never silences it, held or not. + +A first load that rejects has no previous content to keep: born held (D-2), it stays loading until its frame commits, as a first load's value does; under an armed boundary it is the boundary's and shows with the boundary's frame (F-4). A derivation that reads a staged error throws it, as it would read a staged value: its own pass errors and stages with the same frame. + +**What it does not say.** It does not say an error keeps a hold open. It does not say an unheld error waits: with nothing held, an error publishes at the flush that found it, like a value (S-38). It does not say `latest` hides the error. It does not change what a boundary catches (F-12), the effect arms (D-38), or the halt. + +**Deviation today.** A held node's error publishes at once while its value would wait: the effect's error arm runs (with no boundary between), the untracked read throws, `isPending` reads `false`, the promise helpers reject; under a scheduled render effect the screen itself does hold. During a retry the untracked read of a shown error serves the pre-error value, and a held recovery drops the shown error ahead of its frame. A recovery to the last good value after an async rejection never reaches the effect, held or not (its equality gate ran before the status changed). From #3886's audit, which also found the standalone cases listed in `OPEN-QUESTIONS.md` Q-6. Pinned `it.fails` in `tests/error-is-an-answer.test.ts`. **Status:** proposed. + +--- + ## Flagged — rulings that cannot cite a fundamental, stale archive text, mistakes | item | finding | disposition | diff --git a/packages/signals/docs/RULES-FUNDAMENTALS.md b/packages/signals/docs/RULES-FUNDAMENTALS.md index 8bc32d74ef..081ca6e972 100644 --- a/packages/signals/docs/RULES-FUNDAMENTALS.md +++ b/packages/signals/docs/RULES-FUNDAMENTALS.md @@ -121,9 +121,9 @@ These are the only words the fundamentals use. Internal names (transaction, stag **Immutable.** An error thrown by content under an `` sends that boundary to its error fallback, which is then the display (F-3): readers behind it hold nothing. A pending read is never an error: `` does not catch it and never shows its fallback for a wait, and `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning, not a failure. An error that escapes every boundary halts the reactive system (`REACTIVITY_HALTED`); later writes are ignored. In effects, compute-phase errors can be intercepted (`createEffect`'s error arm) and are logged and skipped when they are not; effect-phase errors escalate to the nearest boundary. -**What it does not say.** It does not say an error clears on the next write — the error fallback stays until a new answer replaces the errored one (D-39). +**What it does not say.** It does not say an error clears on the next write — the error fallback stays until a new answer replaces the errored one (D-39). It does not say an error shows before the frame it belongs to: an error is an answer — it ends the wait like a value, and it shows when a value would (D-40, proposed). -**Collapses:** A1–A6, §23.1 items 2–3 (2026-10-01), the 7b re-pin (2026-10-02), recovery 9.1/9.2. **Derived:** D-38, D-39. **Scenarios:** S-38. +**Collapses:** A1–A6, §23.1 items 2–3 (2026-10-01), the 7b re-pin (2026-10-02), recovery 9.1/9.2. **Derived:** D-38, D-39, D-40 (proposed). **Scenarios:** S-38, S-41, S-42. --- diff --git a/packages/signals/docs/SCENARIOS.md b/packages/signals/docs/SCENARIOS.md index fbcdc23ce4..6bf0805980 100644 --- a/packages/signals/docs/SCENARIOS.md +++ b/packages/signals/docs/SCENARIOS.md @@ -757,7 +757,7 @@ _Pinned: `tests/optimistic-lane-release.test.ts`, `tests/body-end-supersession-v --- -## E. First loads, stores, errors (D-6, D-30, D-38–D-39) +## E. First loads, stores, errors (D-6, D-30, D-38–D-40) ### S-36. A declared first paint is loading, not pending — D-30 (A27) @@ -814,3 +814,39 @@ const data = createMemo(() => fetchOrFail(count())); // rejects for every co | ✗ | | after 1: `1 \| loading` (the error clearing for a pending); or `0` held by the errored flight | | `` never catches a pending: `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning (A6). + +### S-41. A held rejection reveals with its frame — D-40 (proposed; #3886) + +_Pinned `it.fails`: `tests/error-is-an-answer.test.ts`._ + +```tsx +const [count, setCount] = createSignal(1); +const data = createMemo(() => fetchOrFail(count())); // rejects for count 2 +const slow = createMemo(() => fetchSlow(count())); // answers later than data +

{count()}

+ `Error ${e().count}`}>{data()} +

{slow()}

+createEffect(data, { effect: v => log(v), error: e => log(e) }); +``` + +| # | Action | Screen | `data()` untracked | `isPending(data)` | `latest(data)` | effect log | Notes | +| --- | ------------------------ | ---------------------------------------------------------------------------------------- | ------------------ | ----------------- | ---------------- | ------------ | ------------------------------------------------------------- | +| 0 | both landed for 1 | `1 \| 1 \| 1` | 1 | false | 1 | `1` | | +| 1 | `setCount(2); flush()` | `1 \| 1 \| 1` — held: `slow(2)` is in flight | 1 | true | throws NotReady | `1` | a change that needs async is held (F-2) | +| 2 | `fetchOrFail(2)` rejects | `1 \| 1 \| 1` — still held on `slow(2)`; the error settled `data` | 1 | true | throws `Error 2` | `1` | settled, not shown: the error is staged like a value would be | +| 3 | `fetchSlow(2)` lands | `2 \| Error 2 \| 2` | throws `Error 2` | false | throws `Error 2` | `1, Error 2` | one frame: input, error fallback and sibling | +| ✗ | | after 2: `1 \| Error 2 \| 1` with the arm already run — today's screen, F-1's torn frame | | | | | | + +The hold did not wait on the rejection: step 3 lands as soon as `slow(2)` does, whether `data(2)` rejected before or after it. + +### S-42. A held recovery keeps the shown error; recovering to the last good value is a change — D-40, D-39 (proposed; #3886) + +_Pinned `it.fails`: `tests/error-is-an-answer.test.ts`._ Continues S-41 from step 3; `fetchOrFail(3)` resolves to `1`, the value shown before the error. + +| # | Action | Screen | `data()` untracked | `isPending(data)` | effect log | Notes | +| --- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------- | --------------- | ---------------------------------------------------------------------- | +| 3 | (S-41's end) | `2 \| Error 2 \| 2` | throws `Error 2` | false | `1, Error 2` | | +| 4 | `setCount(3); flush()` | `2 \| Error 2 \| 2` — held: both re-ask | throws `Error 2` | true | `1, Error 2` | the error stays the display through the retry (D-39) | +| 5 | `fetchOrFail(3)` resolves `1` | `2 \| Error 2 \| 2` — still held on `slow(3)` | throws `Error 2` | true | `1, Error 2` | settled, not shown | +| 6 | `fetchSlow(3)` lands | `3 \| 1 \| 3` | 1 | false | `1, Error 2, 1` | `1` equals the last good value and is still a new answer: the arm runs | +| ✗ | | after 5: `2 \| 1 \| 2` (the error clearing ahead of its frame); after 6: the arm never runs with `1` — today, and today also with nothing held at all (an async landing equal to the last good value is swallowed by the equality gate while the node is errored) | | | | | From 84306662d7cfd4c20513c355395cadbb33260cf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:18:20 +0000 Subject: [PATCH 3/7] =?UTF-8?q?docs(signals):=20Q-6=20=E2=80=94=20what=20D?= =?UTF-8?q?-40=20costs=20(prototype);=20pins=20unwrap=20the=20status=20err?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr --- packages/signals/docs/OPEN-QUESTIONS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/signals/docs/OPEN-QUESTIONS.md b/packages/signals/docs/OPEN-QUESTIONS.md index 3d64593297..478845530f 100644 --- a/packages/signals/docs/OPEN-QUESTIONS.md +++ b/packages/signals/docs/OPEN-QUESTIONS.md @@ -24,6 +24,8 @@ The sequence is S-41: `count` 1 → 2; `data(2)` rejects while the sibling `slow **Where the record leans.** B. F-1 (the fallback is content, F-4, and `Error 2` beside `slow`'s `1` is a value beside a stale derivation of its input); F-2 (a change that needs async shows nothing of itself until that async has landed; the error is part of the change); D-39's reading of the mirror. A was never ruled: the engine publishes an error straight into the node's working status, which is also what reads and effects see, so the error has one state where a value has two (staged, committed). "An error is the resolved future" is true of what the hold waits on, and both columns agree there. +**What B costs (prototype, 2026-10-12, branch `proto/staged-error`).** An error a held pass finds is staged in `_pendingValue` (a config bit marks it), propagated to dependents as a staging of their own by the same walk a published error takes, thrown to readers in the hold, and published by the commit when the frame lands; a held recovery keeps the shown error until its landing. S-41 passes in full, S-42 except the untracked read through the retry (the re-ask replaces the shown error with its pending mark, D-39's open deviation), the full suite is otherwise green, and #3886's own tests go from 43 to 35 failures (the rest are the standalone bugs and its contract changes). Untuned cost: **+243 B brotli / +764 B minified on the core floor**, about the same on every scenario, 230 B over the caps. The mechanism duplicates the immediate error path rather than replacing it; a size-neutral build would make every error stage (a plain flush commits it at its seam) and retire the immediate path, which is a larger change than the prototype. + **Not in the question.** The standalone bugs #3886 found beside this, each its own fix with no rule: an async recovery to the last good value never notifies the effect even with nothing held; a write to a failed writable memo does not clear its error; a thrown value whose `toString`/`message` throws reaches the arm as the formatter's error; a failing async comparator does not release unobserved lazy readers; snapshot scopes do not capture a failure. ### Q-7. `loadingValue` after a shown failure: does a pending retry serve the seed again? (from #3886) From 0d3e82aef19d558088561dc5141b275d3b64b130 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:07:06 +0000 Subject: [PATCH 4/7] fix(signals): a held error waits for its frame (D-41) An error a held node's flight or pass finds is staged like a value and published by the frame's commit through the ordinary error path; the separate staged-error publish path is gone. A held recovery keeps the shown error until its own commit, a recovery equal to the last good value reaches effects, latest() throws a staged error (born held too), and watch() (resolve/until/refresh) rejects at the landing. Rules: D-40 (#3995, proposed) renumbered D-41 and ruled (Q-6 B); S-41/S-42 are S-42/S-43; Q-6 removed from OPEN-QUESTIONS. The derived-store untracked read is the open deviation, pinned it.fails. watch-error-outcomes.test.ts is from #3886. Co-authored-by: Gabriel Valfridsson Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju --- .changeset/held-error-waits-for-frame.md | 5 + packages/signals/docs/OPEN-QUESTIONS.md | 28 +--- packages/signals/docs/RULES-DERIVED.md | 8 +- packages/signals/docs/RULES-FUNDAMENTALS.md | 4 +- packages/signals/docs/SCENARIOS.md | 38 +++--- packages/signals/src/core/async.ts | 120 ++++++------------ packages/signals/src/core/constants.ts | 2 +- packages/signals/src/core/core.ts | 51 ++++---- packages/signals/src/core/scheduler.ts | 31 ++--- packages/signals/src/core/types.ts | 5 + packages/signals/src/core/verdict.ts | 6 +- packages/signals/src/signals.ts | 9 +- .../signals/tests/error-is-an-answer.test.ts | 74 +++++++++-- .../tests/watch-error-outcomes.test.ts | 70 ++++++++++ 14 files changed, 266 insertions(+), 185 deletions(-) create mode 100644 .changeset/held-error-waits-for-frame.md create mode 100644 packages/signals/tests/watch-error-outcomes.test.ts diff --git a/.changeset/held-error-waits-for-frame.md b/.changeset/held-error-waits-for-frame.md new file mode 100644 index 0000000000..c02066ccc1 --- /dev/null +++ b/.changeset/held-error-waits-for-frame.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An error is an answer: it ends the wait like a value, and it shows when a value would (D-41). A rejection or throw on a node a transaction holds is staged and published with the frame that carries it: until then the screen, an untracked read, `createEffect`'s error arm and `` keep the previous frame, `isPending` is `true`, `latest` throws the proposed error, and `resolve`/`until`/`refresh` reject at the landing. A held recovery keeps the shown error until its own commit, and a recovery equal to the last good value now reaches effects, held or not (it was swallowed by the equality gate). Replaces the separate staged-error publish path. From #3886's audit (GabbeV). diff --git a/packages/signals/docs/OPEN-QUESTIONS.md b/packages/signals/docs/OPEN-QUESTIONS.md index 478845530f..c9e5c96c6a 100644 --- a/packages/signals/docs/OPEN-QUESTIONS.md +++ b/packages/signals/docs/OPEN-QUESTIONS.md @@ -2,32 +2,12 @@ **Status: one batch, 2026-10-07.** These are the places where two sources genuinely disagree, or where a ruling was read two ways within a day. The worker did not decide them. Each is laid out as a sequence with the candidate screens side by side; the answer is a column letter. Where the record already leans one way, that is said, with the evidence — but the fundamental is fixed by your answer here, not by the record. -Three sections were drafted. **Q** stays while a ruled screen is still written out here; Q-6 and Q-7 (2026-10-12, from #3886) are open. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, are now carried by the fundamentals, the derived rules and the scenarios, and are deleted. **U** — ruled, not built (`it.fails` pins), confirm the ruling stands and say whether it is for 2.0. This file goes when U is answered. +Three sections were drafted. **Q** stays while a ruled screen is still written out here; Q-7 (2026-10-12, from #3886) is open. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, and Q-6, ruled 2026-10-15 (B, now D-41), are carried by the fundamentals, the derived rules and the scenarios, and are deleted. **U** — ruled, not built (`it.fails` pins), confirm the ruling stands and say whether it is for 2.0. This file goes when U is answered. --- ## Q. Proposed, not ruled — the answer is a column letter -### Q-6. Does an error wait for its frame the way a value does? (D-40, from #3886) - -The sequence is S-41: `count` 1 → 2; `data(2)` rejects while the sibling `slow(2)` is still in flight; an `` around `data`, a `createEffect(data, { effect, error })`, and `

{count()}

` beside it. - -| | **A — today** | **B — D-40** | -| -------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------- | -| screen after `data(2)` rejects, `slow(2)` up | `1 \| Error 2 \| 1` | `1 \| 1 \| 1` | -| the error arm | runs now | runs when `slow(2)` lands, in the frame that shows `2` | -| `data()` untracked, meanwhile | throws `Error 2` | `1` (the screen) | -| `isPending(data)` / `latest(data)`, meanwhile | `false` / throws `Error 2` | `true` / throws `Error 2` | -| `resolve(data)` / `until` / `refresh` called in the hold | reject now | reject at the landing (D-31: delivered by the hold they are called in) | -| what the hold waits for | `slow(2)` | `slow(2)` — unchanged in both columns | -| recovery to the last good value (S-42 step 6) | the arm never runs; the screen shows `1` with the arm still on `Error 2` | the arm runs with `1` | - -**Where the record leans.** B. F-1 (the fallback is content, F-4, and `Error 2` beside `slow`'s `1` is a value beside a stale derivation of its input); F-2 (a change that needs async shows nothing of itself until that async has landed; the error is part of the change); D-39's reading of the mirror. A was never ruled: the engine publishes an error straight into the node's working status, which is also what reads and effects see, so the error has one state where a value has two (staged, committed). "An error is the resolved future" is true of what the hold waits on, and both columns agree there. - -**What B costs (prototype, 2026-10-12, branch `proto/staged-error`).** An error a held pass finds is staged in `_pendingValue` (a config bit marks it), propagated to dependents as a staging of their own by the same walk a published error takes, thrown to readers in the hold, and published by the commit when the frame lands; a held recovery keeps the shown error until its landing. S-41 passes in full, S-42 except the untracked read through the retry (the re-ask replaces the shown error with its pending mark, D-39's open deviation), the full suite is otherwise green, and #3886's own tests go from 43 to 35 failures (the rest are the standalone bugs and its contract changes). Untuned cost: **+243 B brotli / +764 B minified on the core floor**, about the same on every scenario, 230 B over the caps. The mechanism duplicates the immediate error path rather than replacing it; a size-neutral build would make every error stage (a plain flush commits it at its seam) and retire the immediate path, which is a larger change than the prototype. - -**Not in the question.** The standalone bugs #3886 found beside this, each its own fix with no rule: an async recovery to the last good value never notifies the effect even with nothing held; a write to a failed writable memo does not clear its error; a thrown value whose `toString`/`message` throws reaches the arm as the formatter's error; a failing async comparator does not release unobserved lazy readers; snapshot scopes do not capture a failure. - ### Q-7. `loadingValue` after a shown failure: does a pending retry serve the seed again? (from #3886) `createMemo(() => fetch(id()), { loadingValue: 42 })`; the first request rejects and the error shows; then `setId(2)` re-asks. @@ -37,9 +17,9 @@ The sequence is S-41: `count` 1 → 2; `data(2)` rejects while the sibling `slow | screen while the retry is in flight | `42` — the seed serves again; `isPending` is `false` | the error stays; `isPending` is `true` | | the seed as memo `prev` | `42` | `42` (unchanged: the seed stays the last successful `prev`) | -**Where the record leans.** B is D-39's wording applied literally ("the error stays until a new answer replaces it; a pending is not a value"), and D-30 says a declared first paint is _loading_, not pending, which is A's ground: the seed is the first paint until a first answer. A revealed failure is a first answer, which favors B. A is pinned twice today and was ruled for #2989 before D-39 existed. Not needed for Q-6; decide on its own. +**Where the record leans.** B is D-39's wording applied literally ("the error stays until a new answer replaces it; a pending is not a value"), and D-30 says a declared first paint is _loading_, not pending, which is A's ground: the seed is the first paint until a first answer. A revealed failure is a first answer, which favors B. A is pinned twice today and was ruled for #2989 before D-39 existed. D-41 (ruled) does not depend on it; decide on its own. -**Answer format that is enough:** `Q-6 B, Q-7 A` — or the line you prefer. +**Answer format that is enough:** `Q-7 A` — or the line you prefer. --- @@ -60,4 +40,4 @@ Each is pinned `it.fails`; the ruled screen is in `SCENARIOS.md`. Confirming her | **U-9** | D-14 — a held tree under a re-armed boundary re-derives without a redraw loop | `loading-fallback-in-flush-3540.test.ts:1020` (the loop), `:520` (re-armed held mounts), `:859` (committed content under a re-armed boundary); `loading-on-frame-following-3540.test.ts` §3b, §3c | built (2026-10-14): a re-arm owns what an earlier hold staged under it (`arm` records it, `heldUnder`), and a pass under it that reads a hold is the boundary's, not the tick's (`_fresh`): the fallback shows now, the content at that hold's commit, and the hold does not wait for it. A re-arm whose own flush joins that hold folds back into it (D-16): the seam drops a fallback staged into the very hold its readers wait on. The pins are plain `it` | 0 B minified on the floors (optional calls golfed in the core); +417 B minified on the boundary apps, caps raised (accepted 2026-10-14) | | **U-10** | F-1, F-6 — a memo that reads a verdict memo shows the verdict now, beside the held value it also reads (#4050) | `verdict-memo-under-held-memo-4050.test.ts`; web `held-shell-pending-feedback-4050.spec.tsx` (a Provider's children memo over a router shell) | built (2026-10-14): a memo's tracked read of a verdict reader makes it one (`read`, core.ts; CONFIG_VERDICT shares REACTIVE_PROBED's bit), so it reads the held value as the screen and re-derives in the verdict lane, as the inline probe does; boundaries.ts `verdictDerived` folds into the owner's own mark | +26 B minified on the floors (no cap raised: +18 B over the recorded minified, within the allowance), −66…−79 B minified on the boundary apps and pages | -**Answer format that is enough:** `U-1, U-3–U-9 stand, U-5 (#4071) and U-6 after 2.0` — or the line you prefer. U-2 is withdrawn with the direction rule's time half (once drafted as F-8; F-2). Q-1 through Q-5 are ruled and carried by the rules. C-1 is D-12. C-2 is D-7. C-3 is D-32. +**Answer format that is enough:** `U-1, U-3–U-9 stand, U-5 (#4071) and U-6 after 2.0` — or the line you prefer. U-2 is withdrawn with the direction rule's time half (once drafted as F-8; F-2). Q-1 through Q-6 are ruled and carried by the rules. C-1 is D-12. C-2 is D-7. C-3 is D-32. diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index c2a147223e..cdec76dc37 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -334,9 +334,9 @@ An `` flipping to its fallback is a boundary going back to its fallback --- -### D-40. An error is an answer: it ends the wait like a value, and it shows when a value would +### D-41. An error is an answer: it ends the wait like a value, and it shows when a value would -**From:** F-2, F-1, F-12, D-39. **Scenarios:** S-41, S-42. **Proposed 2026-10-12, not ruled** (Q-6). +**From:** F-2, F-1, F-12, D-39. **Scenarios:** S-42, S-43. **Ruled 2026-10-15** (Q-6 B, from #3886; first drafted as D-40 in #3995). A rejection, a compute-phase throw or a comparator throw is the answer to the question its node asked. As an answer it _settles_ the async: a hold never waits on an errored flight, and nothing here changes what a hold waits for. But settled is not shown. A value that lands on a held node is staged until the frame reveals (F-2); an error that lands on a held node is staged the same way, because the error fallback is content (F-4) and showing it beside the previous frame's other content is the torn screen F-1 forbids. Until the reveal: the screen keeps what it showed, an ordinary outside read serves the committed value (it reads the screen, D-29, D-34), `createEffect`'s error arm does not run, `` does not flip, `resolve`/`until`/`refresh` do not reject (D-31), `isPending` of it is `true`, and `latest` of it throws the proposed error — the one way to see the answer early (F-6). At the reveal the error and everything that waited with it show as one frame: the arm runs, the boundary shows its fallback, the outside read throws, `isPending` is `false`. @@ -346,7 +346,9 @@ A first load that rejects has no previous content to keep: born held (D-2), it s **What it does not say.** It does not say an error keeps a hold open. It does not say an unheld error waits: with nothing held, an error publishes at the flush that found it, like a value (S-38). It does not say `latest` hides the error. It does not change what a boundary catches (F-12), the effect arms (D-38), or the halt. -**Deviation today.** A held node's error publishes at once while its value would wait: the effect's error arm runs (with no boundary between), the untracked read throws, `isPending` reads `false`, the promise helpers reject; under a scheduled render effect the screen itself does hold. During a retry the untracked read of a shown error serves the pre-error value, and a held recovery drops the shown error ahead of its frame. A recovery to the last good value after an async rejection never reaches the effect, held or not (its equality gate ran before the status changed). From #3886's audit, which also found the standalone cases listed in `OPEN-QUESTIONS.md` Q-6. Pinned `it.fails` in `tests/error-is-an-answer.test.ts`. **Status:** proposed. +**Built 2026-10-15.** An error a held node's flight or pass finds is staged in `_pendingValue` (CONFIG_STAGED_ERROR) and committed with the frame, which publishes it through the ordinary error path; a held recovery keeps the shown error (`_shownError`) until its own commit, and a recovery equal to the last good value is a change of answer, held or not. `watch` (`resolve`, `until`, `refresh`) rejects at the landing. The duplicated immediate-publish path is gone. Pins: `tests/error-is-an-answer.test.ts`, `tests/watch-error-outcomes.test.ts` (GabbeV, #3886). + +**Deviation today.** An untracked read of a derived store's property (`createStore(fn)`, projections) throws a held rejection before its frame; the store's effect and the screen do wait. Pinned `it.fails` in `tests/error-is-an-answer.test.ts`. The standalone cases from #3886's audit (a write to a failed writable memo, error-wrapper formatting, comparator release, snapshot capture) are not this rule. **Status:** standing. --- diff --git a/packages/signals/docs/RULES-FUNDAMENTALS.md b/packages/signals/docs/RULES-FUNDAMENTALS.md index 081ca6e972..aaf62d9797 100644 --- a/packages/signals/docs/RULES-FUNDAMENTALS.md +++ b/packages/signals/docs/RULES-FUNDAMENTALS.md @@ -121,9 +121,9 @@ These are the only words the fundamentals use. Internal names (transaction, stag **Immutable.** An error thrown by content under an `` sends that boundary to its error fallback, which is then the display (F-3): readers behind it hold nothing. A pending read is never an error: `` does not catch it and never shows its fallback for a wait, and `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning, not a failure. An error that escapes every boundary halts the reactive system (`REACTIVITY_HALTED`); later writes are ignored. In effects, compute-phase errors can be intercepted (`createEffect`'s error arm) and are logged and skipped when they are not; effect-phase errors escalate to the nearest boundary. -**What it does not say.** It does not say an error clears on the next write — the error fallback stays until a new answer replaces the errored one (D-39). It does not say an error shows before the frame it belongs to: an error is an answer — it ends the wait like a value, and it shows when a value would (D-40, proposed). +**What it does not say.** It does not say an error clears on the next write — the error fallback stays until a new answer replaces the errored one (D-39). It does not say an error shows before the frame it belongs to: an error is an answer — it ends the wait like a value, and it shows when a value would (D-41). -**Collapses:** A1–A6, §23.1 items 2–3 (2026-10-01), the 7b re-pin (2026-10-02), recovery 9.1/9.2. **Derived:** D-38, D-39, D-40 (proposed). **Scenarios:** S-38, S-41, S-42. +**Collapses:** A1–A6, §23.1 items 2–3 (2026-10-01), the 7b re-pin (2026-10-02), recovery 9.1/9.2. **Derived:** D-38, D-39, D-41. **Scenarios:** S-38, S-42, S-43. --- diff --git a/packages/signals/docs/SCENARIOS.md b/packages/signals/docs/SCENARIOS.md index 6bf0805980..5045765685 100644 --- a/packages/signals/docs/SCENARIOS.md +++ b/packages/signals/docs/SCENARIOS.md @@ -757,7 +757,7 @@ _Pinned: `tests/optimistic-lane-release.test.ts`, `tests/body-end-supersession-v --- -## E. First loads, stores, errors (D-6, D-30, D-38–D-40) +## E. First loads, stores, errors (D-6, D-30, D-38, D-39, D-41) ### S-36. A declared first paint is loading, not pending — D-30 (A27) @@ -815,9 +815,9 @@ const data = createMemo(() => fetchOrFail(count())); // rejects for every co `` never catches a pending: `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning (A6). -### S-41. A held rejection reveals with its frame — D-40 (proposed; #3886) +### S-42. A held rejection reveals with its frame — D-41 (#3886) -_Pinned `it.fails`: `tests/error-is-an-answer.test.ts`._ +_Pinned: `tests/error-is-an-answer.test.ts`._ ```tsx const [count, setCount] = createSignal(1); @@ -829,24 +829,24 @@ const slow = createMemo(() => fetchSlow(count())); // answers later than data createEffect(data, { effect: v => log(v), error: e => log(e) }); ``` -| # | Action | Screen | `data()` untracked | `isPending(data)` | `latest(data)` | effect log | Notes | -| --- | ------------------------ | ---------------------------------------------------------------------------------------- | ------------------ | ----------------- | ---------------- | ------------ | ------------------------------------------------------------- | -| 0 | both landed for 1 | `1 \| 1 \| 1` | 1 | false | 1 | `1` | | -| 1 | `setCount(2); flush()` | `1 \| 1 \| 1` — held: `slow(2)` is in flight | 1 | true | throws NotReady | `1` | a change that needs async is held (F-2) | -| 2 | `fetchOrFail(2)` rejects | `1 \| 1 \| 1` — still held on `slow(2)`; the error settled `data` | 1 | true | throws `Error 2` | `1` | settled, not shown: the error is staged like a value would be | -| 3 | `fetchSlow(2)` lands | `2 \| Error 2 \| 2` | throws `Error 2` | false | throws `Error 2` | `1, Error 2` | one frame: input, error fallback and sibling | -| ✗ | | after 2: `1 \| Error 2 \| 1` with the arm already run — today's screen, F-1's torn frame | | | | | | +| # | Action | Screen | `data()` untracked | `isPending(data)` | `latest(data)` | effect log | Notes | +| --- | ------------------------ | ------------------------------------------------------------------------------------------------ | ------------------ | ----------------- | ---------------- | ------------ | ------------------------------------------------------------- | +| 0 | both landed for 1 | `1 \| 1 \| 1` | 1 | false | 1 | `1` | | +| 1 | `setCount(2); flush()` | `1 \| 1 \| 1` — held: `slow(2)` is in flight | 1 | true | throws NotReady | `1` | a change that needs async is held (F-2) | +| 2 | `fetchOrFail(2)` rejects | `1 \| 1 \| 1` — still held on `slow(2)`; the error settled `data` | 1 | true | throws `Error 2` | `1` | settled, not shown: the error is staged like a value would be | +| 3 | `fetchSlow(2)` lands | `2 \| Error 2 \| 2` | throws `Error 2` | false | throws `Error 2` | `1, Error 2` | one frame: input, error fallback and sibling | +| ✗ | | after 2: `1 \| Error 2 \| 1` with the arm already run — the screen before D-41, F-1's torn frame | | | | | | The hold did not wait on the rejection: step 3 lands as soon as `slow(2)` does, whether `data(2)` rejected before or after it. -### S-42. A held recovery keeps the shown error; recovering to the last good value is a change — D-40, D-39 (proposed; #3886) +### S-43. A held recovery keeps the shown error; recovering to the last good value is a change — D-41, D-39 (#3886) -_Pinned `it.fails`: `tests/error-is-an-answer.test.ts`._ Continues S-41 from step 3; `fetchOrFail(3)` resolves to `1`, the value shown before the error. +_Pinned: `tests/error-is-an-answer.test.ts`._ Continues S-42 from step 3; `fetchOrFail(3)` resolves to `1`, the value shown before the error. -| # | Action | Screen | `data()` untracked | `isPending(data)` | effect log | Notes | -| --- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------- | --------------- | ---------------------------------------------------------------------- | -| 3 | (S-41's end) | `2 \| Error 2 \| 2` | throws `Error 2` | false | `1, Error 2` | | -| 4 | `setCount(3); flush()` | `2 \| Error 2 \| 2` — held: both re-ask | throws `Error 2` | true | `1, Error 2` | the error stays the display through the retry (D-39) | -| 5 | `fetchOrFail(3)` resolves `1` | `2 \| Error 2 \| 2` — still held on `slow(3)` | throws `Error 2` | true | `1, Error 2` | settled, not shown | -| 6 | `fetchSlow(3)` lands | `3 \| 1 \| 3` | 1 | false | `1, Error 2, 1` | `1` equals the last good value and is still a new answer: the arm runs | -| ✗ | | after 5: `2 \| 1 \| 2` (the error clearing ahead of its frame); after 6: the arm never runs with `1` — today, and today also with nothing held at all (an async landing equal to the last good value is swallowed by the equality gate while the node is errored) | | | | | +| # | Action | Screen | `data()` untracked | `isPending(data)` | effect log | Notes | +| --- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------- | --------------- | ---------------------------------------------------------------------- | +| 3 | (S-42's end) | `2 \| Error 2 \| 2` | throws `Error 2` | false | `1, Error 2` | | +| 4 | `setCount(3); flush()` | `2 \| Error 2 \| 2` — held: both re-ask | throws `Error 2` | true | `1, Error 2` | the error stays the display through the retry (D-39) | +| 5 | `fetchOrFail(3)` resolves `1` | `2 \| Error 2 \| 2` — still held on `slow(3)` | throws `Error 2` | true | `1, Error 2` | settled, not shown | +| 6 | `fetchSlow(3)` lands | `3 \| 1 \| 3` | 1 | false | `1, Error 2, 1` | `1` equals the last good value and is still a new answer: the arm runs | +| ✗ | | after 5: `2 \| 1 \| 2` (the error clearing ahead of its frame); after 6: the arm never runs with `1` — before D-41, and then also with nothing held at all (an async landing equal to the last good value is swallowed by the equality gate while the node is errored) | | | | | diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index eb21758d6a..10b3a67f7c 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -30,9 +30,9 @@ import { cleanup } from "./owner.js"; import { clock, flush, + flushTransaction, globalQueue, GlobalQueue, - holdNode, joinFuture, MAINLINE_QUESTION, passLane, @@ -171,52 +171,38 @@ export function releaseSettledDependents(el: Computed): void { // (releaseSettledDependents shape): identity holders can sit below an // intermediate whose own error state has since been scrubbed or replaced // (e.g. an error boundary's tree node). -/** D-40 (proposed): an error a pass of a held node found is an answer of +/** D-41: an error a pass of a held node found is an answer of * that frame — it ends the wait like a value and shows when a value would. * Staged like one: `_pendingValue` carries it (CONFIG_STAGED_ERROR), readers - * in the hold derive from it (`serve` throws it), and the landing publishes - * it (`commitPendingNode` → `notifyStatus`). Not for a lane pass, nor a node - * with no shown value (born held — its error has no frame to wait behind): - * those publish now, as before. Returns whether the error was staged. */ + * in the hold derive from it (`serve` throws it), and the commit publishes + * it through the ordinary error path (`commitPendingNode` → `notifyStatus`), + * dependents included. Not for a lane pass, nor a node with no shown value + * (born held — its error has no frame to wait behind): those publish now, + * as before. Returns whether the error was staged. */ export function stageError(el: Computed, error: any, inPass?: boolean): boolean { - // Held, or — for a pass's own throw — derived from a staging of this flush - // (REACTIVE_STAGED_READ is this pass's; a landing after the pass cannot - // read it, the flush it read a staging of has committed or parked since). + // Held, or — for a pass's own throw — derived from a staging of a flush + // that has joined a hold (REACTIVE_STAGED_READ is this pass's; a landing + // after the pass cannot read it, the flush it read a staging of has + // committed or parked since). A plain flush publishes now (S-38). if ( - !(el._config & CONFIG_HELD || (inPass && el._flags & REACTIVE_STAGED_READ)) || - el._statusFlags & STATUS_UNINITIALIZED || + !( + el._config & CONFIG_HELD || + (inPass && el._flags & REACTIVE_STAGED_READ && flushTransaction !== null) + ) || passLane !== null ) return false; - if (!(error instanceof StatusError)) error = new StatusError(el, error); - const wasSource = !!el._x?._pendingSources?.has(el); - stageErrorOn(el, error); - // Dependents inherit it as a staging of their own (one identity down the - // tree, as a published error propagates) and commit it with this node. - propagateStatus(el, STATUS_ERROR, error, undefined, true); - if (wasSource) settlePendingSource(el); - return true; -} - -/** The staging itself: the node answered (an error ends the wait — no - * longer pending, nor blocked), a shown error stays its display until the - * commit, and `_pendingValue` carries the new one. */ -function stageErrorOn(el: Computed, error: any): void { + // The node answered (an error ends the wait — no longer pending, nor + // blocked); a shown error stays its display until the commit. clearPendingSources(el); if (el._x !== null) el._x._blocked = false; - if (!(el._statusFlags &= STATUS_ERROR)) setPendingError(el); - el._pendingValue = error; + if (!((el._statusFlags &= STATUS_ERROR | STATUS_UNINITIALIZED) & STATUS_ERROR)) + setPendingError(el); + el._pendingValue = error instanceof StatusError ? error : new StatusError(el, error); el._config |= CONFIG_STAGED | CONFIG_STAGED_ERROR; -} - -/** The commit of a staged error (`stageError`): the node's own status — no - * propagation, its dependents staged the same error and commit their own. */ -export function publishError(el: Computed, error: any): void { - clearPendingSources(el); - el._statusFlags = STATUS_ERROR; - ext(el)._error = error; - const notify = statusNotifierOf(el); - if (notify) notify.call(el); + // Readers parked on the flight re-run and derive from the staging. + settlePendingSource(el); + return true; } export function settleErroredDependents(el: Computed, error: any): void { @@ -480,15 +466,11 @@ export function handleAsync( el._time = clock; return; } - // (A derived store's readers learn an error through its family, not the - // derive's dependents: it publishes now — residual, prototype.) - if (!stillPending && !setter && stageError(el, error)) { - el._time = clock; - onError?.(error, false); - schedule(); - return; - } - notifyStatus(el, stillPending ? STATUS_PENDING : STATUS_ERROR, error); + // D-41: a held node's error is staged (`stageError`). A derived store's + // readers learn its error through the family, not the derive's + // dependents: it publishes now. + if (stillPending || setter || !stageError(el, error)) + notifyStatus(el, stillPending ? STATUS_PENDING : STATUS_ERROR, error); // A NotReady rejection is a landing into another pending source. The // rejected flight will never settle its self entry, so transfer ownership // after notifyStatus has propagated the replacement source. @@ -508,13 +490,7 @@ export function handleAsync( // with the new value, creating a fresh Promise that supersedes this one. if (el._flags & REACTIVE_DIRTY) return; const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED); - // D-40 (proposed): a held node's shown error stays shown until the - // landing commits the recovery (`commitPendingNode` clears it there); an - // unheld one clears now. Either way the dependents holding the error - // re-run, a same-value recovery included (#3886). - const published = el._statusFlags & STATUS_ERROR ? el._x!._error : undefined; - const keep = published !== undefined && (el._config & CONFIG_HELD) !== 0 && !wasUninitialized; - if (!keep) landStatus(el); + landStatus(el); // Attribution hook: lets the engine snapshot state before the landing // branches, so it can tell whether the plain path's setSignal committed a // change (and only then classify it as an async landing). @@ -526,6 +502,9 @@ export function handleAsync( handleError(error); return; } + // A store derive's landing commits through its setter: unheld, the + // shown error leaves now; held, at the derive's commit. + if (!(el._config & CONFIG_HELD)) el._x!._shownError = undefined; if (wasUninitialized) landStatus(el, true); // Attribution hook: this path landed through the setter (derived // stores, projections), which does not go through setSignal, so the @@ -549,8 +528,6 @@ export function handleAsync( // pre-commit failure here, and there is no user callsite to throw to. notifyStatus(el, STATUS_ERROR, e); } - if (!keep && published !== undefined && el._pendingValue === NOT_PENDING) - settleErroredDependents(el, published); // Attribution hook: this path landed through setSignal, whose write // hook already saw any committed change — direct=false lets the engine // reclassify that write as an async landing iff it actually committed. @@ -851,7 +828,7 @@ export function clearStatus(el: Computed, clearUninitialized: boolean = fal * reader thrown NotReady(self) would park on a retired entry. Companions * keep their verdict (pending before and after; the write re-syncs them). */ -export function landStatus(el: Computed, clearUninitialized: boolean = false): void { +function landStatus(el: Computed, clearUninitialized: boolean = false): void { const sources = el._x?._pendingSources; // (The full clear below drops the set whether or not self was retired first.) if (sources && (sources.delete(el), sources.size)) { @@ -865,8 +842,7 @@ export function notifyStatus( el: Computed, status: number, error: any, - blockStatus?: boolean, - staged?: boolean + blockStatus?: boolean ): void { // Wrap regular errors to track source node if ( @@ -882,15 +858,13 @@ export function notifyStatus( if (!blockStatus) { if (status === STATUS_PENDING && pendingSource) { + // D-41: a re-ask of a shown error keeps it the display (`_shownError`). + if (el._statusFlags & STATUS_ERROR) el._x!._shownError = el._x!._error; addPendingSource(el, pendingSource); el._statusFlags = STATUS_PENDING | (el._statusFlags & STATUS_UNINITIALIZED); // Preserve the current source on this propagation so readers park on // the distinct pending source. setPendingError(el, pendingSource, error); - } else if (staged) { - // D-40 (proposed): a dependent of a staged error stages it too; its - // status is the commit's (`publishError`), its display unchanged. - stageErrorOn(el, error); } else { clearPendingSources(el); el._statusFlags = @@ -914,7 +888,6 @@ export function notifyStatus( const elNotify = statusNotifierOf(el); if (elNotify) { - if (staged) return; if (blockStatus && status === STATUS_PENDING) { return; } @@ -925,7 +898,7 @@ export function notifyStatus( } return; } - propagateStatus(el, status, error, downstreamBlockStatus, staged); + propagateStatus(el, status, error, downstreamBlockStatus); } /** The dependents' half of `notifyStatus`: `el`'s status reaches each @@ -937,8 +910,7 @@ export function propagateStatus( el: Computed, status: number, error: any, - downstreamBlockStatus?: boolean, - staged?: boolean + downstreamBlockStatus?: boolean ): void { const pendingSource = status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined; @@ -1014,21 +986,7 @@ export function propagateStatus( joinFuture(txOf(sub)); } } - // A re-ask of the source whose error this node shows: whatever its - // settle lands on is a new answer here (the error was the display, - // D-40) — re-run on it (`settlePendingSource`), a landing equal to the - // last good value included (#3886). - if ( - status === STATUS_PENDING && - sub._statusFlags & STATUS_ERROR && - (sub._x!._error as StatusError).source === pendingSource - ) - ext(sub)._blocked = true; - // A staged error from a held node: the dependent's staging is held - // with it (committed at the landing, as the park would list it). - if (staged && el._config & CONFIG_HELD && !(sub._config & (CONFIG_HELD | CONFIG_OVERRIDE))) - holdNode(sub, txOf(el)); - notifyStatus(sub, status, error, downstreamBlockStatus, staged); + notifyStatus(sub, status, error, downstreamBlockStatus); } }); } diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 55de77a4b2..96a28fdb3d 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -123,7 +123,7 @@ export const CONFIG_SLOT_NODE = 1 << 12; * leaf twin of REACTIVE_MANUAL_WRITE). Set by the user setter, cleared by * the derive's own write to the key. */ export const CONFIG_MANUAL_WRITE = 1 << 18; -/** D-40 (proposed): the staged value in `_pendingValue` is an error — an +/** D-41: the staged value in `_pendingValue` is an error — an * answer of the held frame, published at its landing like a value. */ export const CONFIG_STAGED_ERROR = 1 << 19; /** Lanes — the node carries a written guess (`optimisticWrite`): its own diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 103a8dcd06..54006a9df8 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -367,7 +367,7 @@ export function recompute(el: Computed, create: boolean = false): void { // settlePendingSource walk never runs for a landing that was preempted // (#3181). const wasPendingSource = el._x?._pendingSources?.has(el); - // D-40 (proposed): this pass's error is staged, not published (`stageError`). + // D-41: this pass's error is staged, not published (`stageError`). let stagedErr = false; // A19 exc. 2: a `refresh()` re-asks the question already answered — quiet // for the verdict — unless the question in flight is a new one, which the @@ -446,14 +446,16 @@ export function recompute(el: Computed, create: boolean = false): void { // node is currently errored the error stays the answer until this // retry can actually run. parkLoadingWindow(el, e as NotReadyError); - } else if (!notReady && stageError(el, e, true)) { - stagedErr = true; } else { - if (notReady) ext(el)._blocked = true; - notifyStatus(el, notReady ? STATUS_PENDING : STATUS_ERROR, e); - // The replacement source is fully propagated now. If no new flight - // re-owned self, retire the superseded flight and its dependent copies. - if (notReady && wasPendingSource && !el._x?._inFlight) settlePendingSource(el); + // D-41: a pass of a held node, or one that read a staging of this + // flush, stages its error (`stageError`). + if (notReady || !(stagedErr = stageError(el, e, true))) { + if (notReady) ext(el)._blocked = true; + notifyStatus(el, notReady ? STATUS_PENDING : STATUS_ERROR, e); + // The replacement source is fully propagated now. If no new flight + // re-owned self, retire the superseded flight and its dependent copies. + if (notReady && wasPendingSource && !el._x?._inFlight) settlePendingSource(el); + } // A re-park drops what the earlier pass carried (#3456): a source this // pass no longer reaches — its branch switched, or a fresh flight // replaced the inputs' pending with its own — stays copied onto @@ -461,8 +463,9 @@ export function recompute(el: Computed, create: boolean = false): void { // stops at this node (nothing left to retire) before it finds them. A // dependent then waits forever on a flight it has no path to. The // re-park twin of the unchanged-value recovery sweep below; dependents - // with another path keep the source (retryReaches). - if (notReady && outgoingPendingSources) + // with another path keep the source (retryReaches). A staged error + // answered what the pass carried: the same. + if ((notReady || stagedErr) && outgoingPendingSources) for (const source of outgoingPendingSources) if (source !== el && !el._x?._pendingSources?.has(source)) settlePendingSource(el, source); @@ -569,7 +572,10 @@ export function recompute(el: Computed, create: boolean = false): void { let valueChanged = false; try { valueChanged = - (!isEffect && wasUninitialized) || !el._equals || !el._equals(compareValue, value); + (!isEffect && wasUninitialized) || + el._x?._shownError !== undefined || + !el._equals || + !el._equals(compareValue, value); } catch (e) { // A throwing user comparator is an error of this node's computation. // Route it through the same status path as a compute-phase throw so @@ -995,7 +1001,8 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { _flushedAt: -1, _q: 0, _lane: NOT_PENDING, - _marks: 0 + _marks: 0, + _shownError: undefined }); } @@ -1833,13 +1840,7 @@ export function read(el: Signal | Computed): T { } // An errored derive throws for every late reader instead of silently // serving node values (memo parity, #2897 ruling). - // D-40 (proposed): a reader in the hold of a recovery staged over the - // shown error derives from the recovery (`serve`), not the error. - if ( - (owner as Computed)._fn && - (owner as Computed)._statusFlags & STATUS_ERROR && - readerSeesCommitted(el, c as Computed | null) - ) { + if ((owner as Computed)._fn && (owner as Computed)._statusFlags & STATUS_ERROR) { // Only a genuine reactive re-read may retry an errored async source: // - tracking: owned/tracked scope only (never events / `untrack` / effect side-effect phase) // - owner._time < clock: only on a later cycle than the one the error was found @@ -1870,6 +1871,9 @@ export function read(el: Signal | Computed): T { } 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 !== undefined) throw el._x._shownError; const value = serve(el, c as Computed | null) as T; if ( !c && @@ -1924,7 +1928,7 @@ export function serve(el: Signal | Computed, c: Computed | null): ) return el._value; stagedRead(c!, el); - // D-40 (proposed): the staged answer is an error (`stageError`). + // D-41: the staged answer is an error (`stageError`). if (el._config & CONFIG_STAGED_ERROR) throw el._pendingValue; return el._pendingValue; } @@ -2034,10 +2038,11 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T if (el._config & CONFIG_HELD) joinFuture(txOf(el)); // Uninitialized check first: the first commit has no previous value, so the // user comparator must not run against `undefined` (matches recompute). - // D-40 (proposed): a write over a shown error is a change whatever the - // last good value was — the error leaves (`commitPendingNode`). + // D-41: a write over a shown error is a change whatever the last good + // value was — the error leaves at its commit (`commitPendingNode`). const valueChanged = - !!((el as Computed)._statusFlags & (STATUS_UNINITIALIZED | STATUS_ERROR)) || + !!((el as Computed)._statusFlags & STATUS_UNINITIALIZED) || + el._x?._shownError !== undefined || !el._equals || !el._equals(currentValue, v); if (!valueChanged) return v; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index f9e589c30a..e026c8c9a0 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -29,7 +29,7 @@ import { STATUS_UNINITIALIZED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; -import { landStatus, publishError, settleErroredDependents } from "./async.js"; +import { notifyStatus } from "./async.js"; import { ext, markUnflushedStaged, recompute, resyncUnflushedCompanions } from "./core.js"; import { DEV, emitDiagnostic, GRAPH_SIZE_WARN_AT, noteFanOut, reportDiagnostic } from "./dev.js"; import { sweepDormant, trimStaleDeps } from "./graph.js"; @@ -1174,25 +1174,26 @@ export function commitPendingNode(n: Signal): void { // commit beneath a flight publishes its inputs (A15 reveal corollary, // #3305): a reveal of it must observe, not show the pre-flight value. const inFlight = n._pendingValue === NOT_PENDING && (c._statusFlags! & STATUS_PENDING) !== 0; - const held = n._config & CONFIG_HELD; + const err = n._config & CONFIG_STAGED_ERROR; + // An answer commits: the error it replaces leaves the display (D-41). + if (!inFlight && c._x != null) c._x._shownError = undefined; n._config = inFlight ? (n._config & ~(CONFIG_HELD | CONFIG_HELD_FRAME)) | CONFIG_INPUTS_PUBLISHED - : n._config & ~(CONFIG_HELD | CONFIG_HELD_FRAME | CONFIG_STAGED | CONFIG_INPUTS_PUBLISHED); - // D-40 (proposed): the staged answer is an error — published now, when a - // value would show (`stageError`). - if (n._config & CONFIG_STAGED_ERROR) { + : n._config & + ~( + CONFIG_HELD | + CONFIG_HELD_FRAME | + CONFIG_STAGED | + CONFIG_INPUTS_PUBLISHED | + CONFIG_STAGED_ERROR + ); + // D-41: the staged answer is an error — published now, when a value would + // show, through the ordinary error path (`stageError`). + if (err) { const e = n._pendingValue; n._pendingValue = NOT_PENDING; - n._config &= ~CONFIG_STAGED_ERROR; - publishError(c as Computed, e); + notifyStatus(c as Computed, STATUS_ERROR, e); } else if (n._pendingValue !== NOT_PENDING) { - // A held recovery: the shown error leaves with the value that replaces - // it, and the dependents holding it re-run (`asyncWrite` kept it). - if (held && c._statusFlags! & STATUS_ERROR) { - const e = c._x!._error; - landStatus(c as Computed); - settleErroredDependents(c as Computed, e); - } n._value = n._pendingValue as any; n._pendingValue = NOT_PENDING; // A node born staged (recompute) initializes at this commit. diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 0d6d2b2251..6074adfb26 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -111,6 +111,11 @@ export interface NodeExtension { * changes — `isPending` reads true for it and its derivations while any * is live. A count, never a hold. */ _marks: number; + /** D-41: the error a node showed when a re-ask made it pending — still + * the display (D-39) until a new answer commits: an untracked read throws + * it, `isPending` reads a staged answer as a change over it, and a + * recovery equal to the last good value is still a new answer. */ + _shownError: unknown; } export interface RawSignal { diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 1c6b78b38d..77b92677ca 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -211,7 +211,7 @@ function heldNotFinal(owner: Computed): boolean { !latestActive && !owner._x!._reask && !(owner._statusFlags & STATUS_UNINITIALIZED) && - ((owner._statusFlags & STATUS_ERROR) !== 0 || + (owner._x!._shownError !== undefined || !owner._equals || !owner._equals(owner._value as any, owner._pendingValue as any)) ); @@ -288,7 +288,7 @@ export function isPending(fn: () => any): boolean { } } -/** The staging, or the staged error thrown (D-40, proposed). */ +/** The staging, or the staged error thrown (D-41). */ function staged(el: Signal | Computed): unknown { if (el._config & CONFIG_STAGED_ERROR) throw el._pendingValue; return el._pendingValue; @@ -411,7 +411,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): // has not shown content) and answers for the commit, where it is final. if (el._pendingValue !== NOT_PENDING && !(owner._statusFlags & STATUS_PENDING)) { if (probing && owned) joinPass(c!, el); - return el._pendingValue; + return staged(el); } } else if (el._pendingValue !== NOT_PENDING) { if (owner._statusFlags & STATUS_PENDING && !uninitialized) diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 4adbf562f1..2afc5fe29c 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -778,13 +778,15 @@ function watch( // after: the first pass's own error is the promise's rejection.) computed( () => { - let v: T; + let v: T, + failed = false; try { v = fn(); } catch (e) { if (e instanceof NotReadyError) throw e; - queueMicrotask(() => onError(unwrapStatusError(e))); - return; + // An error is an answer (D-41): delivered by the same frame rule. + v = unwrapStatusError(e) as T; + failed = true; } const c = getOwner() as Computed; // (Outside a flush a mainline pass joins through `passTx`.) @@ -797,6 +799,7 @@ function watch( staleReader(c, resolveTx(joined)); return; } + if (failed) return void queueMicrotask(() => onError(v)); inPass?.(v); queueMicrotask(() => onValue(v)); }, diff --git a/packages/signals/tests/error-is-an-answer.test.ts b/packages/signals/tests/error-is-an-answer.test.ts index 7bf399ca46..30ed90e499 100644 --- a/packages/signals/tests/error-is-an-answer.test.ts +++ b/packages/signals/tests/error-is-an-answer.test.ts @@ -1,6 +1,6 @@ /** - * D-40 (proposed): an error is an answer — it ends the wait like a value and - * shows when a value would. S-41, S-42. Shapes from #3886's audit (GabbeV). + * D-41: an error is an answer — it ends the wait like a value and shows when + * a value would. S-42, S-43. Shapes from #3886's audit (GabbeV). * * `data` and `slow` both re-ask on `count`; a scheduled render effect over * `slow` holds the frame (F-2). A rejection of `data` settles it — the hold @@ -8,12 +8,13 @@ * effect's error arm, the untracked read, the boundary and `isPending` all * keep the previous frame; `latest` is the one early view (F-6). Recovery is * the mirror (D-39), and recovering to the last good value is still a new - * answer. + * answer, held or not. * - * Pinned `it.fails` while D-40 is proposed, not ruled (OPEN-QUESTIONS Q-6). - * The no-hold case at the end is a plain bug, pinned as a failing `it`. + * The derived-store case at the end is D-41's open deviation, pinned + * `it.fails`. */ import { expect, it } from "vitest"; +import { createStore } from "../src/index.js"; import { createEffect, createErrorBoundary, @@ -95,7 +96,7 @@ function scene(boundary: boolean) { } it.each([true, false])( - "S-41: a held rejection reveals with its frame (boundary: %s)", + "S-42: a held rejection reveals with its frame (boundary: %s)", async boundary => { const s = scene(boundary); const shown = boundary ? "Error 2" : "no boundary"; @@ -137,7 +138,7 @@ it.each([true, false])( ); it.each([true, false])( - "S-42: a held recovery keeps the shown error; the last good value is a new answer (boundary: %s)", + "S-43: a held recovery keeps the shown error; the last good value is a new answer (boundary: %s)", async boundary => { const s = scene(boundary); const shown = boundary ? "Error 2" : "no boundary"; @@ -184,10 +185,9 @@ it.each([true, false])( } ); -// No hold at all: the same recovery is a plain bug, not a rule question. The -// landing equal to the last good value passes the equality gate while the -// node is errored, so the effect never hears the recovery (#3886). Pinned -// `it.fails` until its own fix lands. +// No hold at all: the landing equal to the last good value used to pass the +// equality gate while the node was errored, so the effect never heard the +// recovery (#3886). it("recovering to the last good value after an async rejection reaches the effect", async () => { const boom = new Error("failed"); const [count, setCount] = createSignal(0); @@ -216,3 +216,55 @@ it("recovering to the last good value after an async rejection reaches the effec flush(); } }); + +// Open deviation: the effect and the screen wait, but an untracked read of a +// derived store's property throws a held rejection before its frame (the +// family publishes the error at once). +it.fails("D-41 deviation: a derived store's held rejection waits for its frame", async () => { + let request = deferred<{ v: number }>(); + let sibling = deferred(); + const [count, setCount] = createSignal(1); + const log: unknown[] = []; + let shown = 0; + let store!: { v: number }; + const dispose = createRoot(d => { + [store] = createStore( + async () => { + count(); + return request.promise; + }, + { v: 0 } + ); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, v => void (shown = v), { schedule: true }); + createEffect(() => store.v, { effect: v => void log.push(v), error: e => void log.push(e) }); + return d; + }); + try { + flush(); + request.resolve({ v: 1 }); + sibling.resolve(1); + await tick(); + expect(log).toEqual([1]); + request = deferred(); + sibling = deferred(); + setCount(2); + flush(); + request.reject(new Error("2")); + await tick(); + expect(shown).toBe(1); + expect(log).toEqual([1]); + expect(store.v).toBe(1); + sibling.resolve(2); + await tick(); + expect(shown).toBe(2); + expect(log.length).toBe(2); + expect((log[1] as Error).message).toBe("2"); + } finally { + dispose(); + flush(); + } +}); diff --git a/packages/signals/tests/watch-error-outcomes.test.ts b/packages/signals/tests/watch-error-outcomes.test.ts new file mode 100644 index 0000000000..faba616acd --- /dev/null +++ b/packages/signals/tests/watch-error-outcomes.test.ts @@ -0,0 +1,70 @@ +import { + createSignal, + createMemo, + createRenderEffect, + createRoot, + flush, + resolve, + until, + refresh, + type SourceAccessor +} from "../src/index.js"; +function deferred() { + let ok!: (v: T) => void, bad!: (e: unknown) => void; + const promise = new Promise((r, j) => { + ok = r; + bad = j; + }); + return { promise, ok, bad }; +} +const tick = async () => { + await new Promise(r => setTimeout(r, 0)); + flush(); +}; +const cases = ["resolve", "until", "refresh"].flatMap(kind => + ["value", "error"].map(outcome => ({ kind, outcome })) +); +it.each(cases)("$kind delivers held $outcome with foreign frame", async ({ kind, outcome }) => { + let request = deferred(), + sibling = deferred(); + let source!: SourceAccessor; + const [count, setCount] = createSignal(1); + const dispose = createRoot(d => { + source = createMemo(() => { + count(); + return request.promise; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + return d; + }); + flush(); + request.ok(1); + sibling.ok(1); + await tick(); + request = deferred(); + sibling = deferred(); + setCount(2); + flush(); + const seen: unknown[] = []; + const delivery = + kind === "refresh" ? refresh(source) : kind === "until" ? until(source) : resolve(source); + const watched = delivery.then( + v => seen.push(v), + e => seen.push(e) + ); + await tick(); + const marker = new Error("failed"); + outcome === "error" ? request.bad(marker) : request.ok(2); + await tick(); + expect(seen).toEqual([]); + sibling.ok(2); + await tick(); + await watched; + expect(seen).toEqual([outcome === "error" ? marker : 2]); + dispose(); + flush(); +}); From 7541c58d0e2b2395f50eacbd58e3f0e98bd9fb74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:19:52 +0000 Subject: [PATCH 5/7] perf(signals): trim the held-error path (-52 B minified on the floor) Truthiness checks for _shownError (always a status error when set), flushTransaction and passLane. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju --- packages/signals/src/core/async.ts | 6 +++--- packages/signals/src/core/core.ts | 6 +++--- packages/signals/src/core/scheduler.ts | 2 +- packages/signals/src/core/verdict.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 10b3a67f7c..f448a9c911 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -187,15 +187,15 @@ export function stageError(el: Computed, error: any, inPass?: boolean): boo if ( !( el._config & CONFIG_HELD || - (inPass && el._flags & REACTIVE_STAGED_READ && flushTransaction !== null) + (inPass && el._flags & REACTIVE_STAGED_READ && flushTransaction) ) || - passLane !== null + passLane ) return false; // The node answered (an error ends the wait — no longer pending, nor // blocked); a shown error stays its display until the commit. clearPendingSources(el); - if (el._x !== null) el._x._blocked = false; + if (el._x) el._x._blocked = false; if (!((el._statusFlags &= STATUS_ERROR | STATUS_UNINITIALIZED) & STATUS_ERROR)) setPendingError(el); el._pendingValue = error instanceof StatusError ? error : new StatusError(el, error); diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 54006a9df8..9971f6a7a0 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -573,7 +573,7 @@ export function recompute(el: Computed, create: boolean = false): void { try { valueChanged = (!isEffect && wasUninitialized) || - el._x?._shownError !== undefined || + !!el._x?._shownError || !el._equals || !el._equals(compareValue, value); } catch (e) { @@ -1873,7 +1873,7 @@ export function read(el: Signal | Computed): T { 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 !== undefined) throw el._x._shownError; + if (!c && el._x?._shownError) throw el._x._shownError; const value = serve(el, c as Computed | null) as T; if ( !c && @@ -2042,7 +2042,7 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T // value was — the error leaves at its commit (`commitPendingNode`). const valueChanged = !!((el as Computed)._statusFlags & STATUS_UNINITIALIZED) || - el._x?._shownError !== undefined || + !!el._x?._shownError || !el._equals || !el._equals(currentValue, v); if (!valueChanged) return v; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index e026c8c9a0..9804ddd6ea 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -1176,7 +1176,7 @@ export function commitPendingNode(n: Signal): 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 != null) c._x._shownError = undefined; + if (!inFlight && c._x) c._x._shownError = undefined; n._config = inFlight ? (n._config & ~(CONFIG_HELD | CONFIG_HELD_FRAME)) | CONFIG_INPUTS_PUBLISHED : n._config & diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 77b92677ca..13dbe08be8 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -211,7 +211,7 @@ function heldNotFinal(owner: Computed): boolean { !latestActive && !owner._x!._reask && !(owner._statusFlags & STATUS_UNINITIALIZED) && - (owner._x!._shownError !== undefined || + (!!owner._x!._shownError || !owner._equals || !owner._equals(owner._value as any, owner._pendingValue as any)) ); From e3f829d2783812f82b0febccd021dc7297f50b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:58:27 +0000 Subject: [PATCH 6/7] fix(signals): a committed held error is not stale to the cycle that shows it commitPendingNode publishes a staged error at the seam, before the clock advances. Readers re-derived by it (a boundary over the failed memo) run on the next cycle, where read() took the error for an old one and retried the async source; the retry rejected again and staged again, forever (web's errored-show-retry-3991 outside variant ran out of memory). Stamp the commit with the cycle that shows it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju --- packages/signals/src/core/scheduler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 9804ddd6ea..966b6378cc 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -1188,10 +1188,13 @@ export function commitPendingNode(n: Signal): void { CONFIG_STAGED_ERROR ); // D-41: the staged answer is an error — published now, when a value would - // show, through the ordinary error path (`stageError`). + // show, through the ordinary error path (`stageError`). Its readers first + // see it in the cycle after this seam: a tracked re-read there is not a + // retry (`read`), or a boundary re-derived by it would retry it forever. if (err) { const e = n._pendingValue; n._pendingValue = NOT_PENDING; + n._time = clock + 1; notifyStatus(c as Computed, STATUS_ERROR, e); } else if (n._pendingValue !== NOT_PENDING) { n._value = n._pendingValue as any; From 609fb7bf78bcdc97260b040818d2ce4e578ef754 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:58:29 +0000 Subject: [PATCH 7/7] chore(size): raise caps for held errors (D-41), accepted 2026-10-15 Size-Exception: the held-error path costs +437 B minified / +167 B brotli on the core floor after a trim pass; each raised cap is measured + 10 B with a dated ledger line. The two compiled server-component pages stay (within their recorded minified). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju --- scripts/size/floor-caps.json | 20 ++-- scripts/size/scenarios.js | 184 ++++++++++++++++++++++++++++++----- 2 files changed, 170 insertions(+), 34 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index b6577b6698..41f7d9b32b 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -1,23 +1,23 @@ { "signals: core floor (createSignal/Memo/Effect/Root/flush)": { - "cap": "7.48 KB", - "minified": 20436 + "cap": "7.66 KB", + "minified": 20892 }, "app: render + one signal (the simple-app floor)": { - "cap": "9.98 KB", - "minified": 27945 + "cap": "10.16 KB", + "minified": 28400 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { - "cap": "18.09 KB", - "minified": 53350 + "cap": "18.27 KB", + "minified": 53816 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "34.334 KB", - "minified": 106704 + "cap": "34.80 KB", + "minified": 107133 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.146 KB", - "minified": 118990 + "cap": "38.60 KB", + "minified": 119426 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index dad791b7fe..cd9a8e1601 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -576,6 +576,14 @@ module.exports = [ // instead of the committed screen. Cap set at measured + 10 B rounded up to // 0.01 KB. Accepted by the maintainer 2026-10-13; hello world stays under // 10 KB at 9,966 B. The cap is frozen again at 7.48 KB. + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 7.48 -> 7.66 KB, measured locally at 7642 B against `next` @ + // 3fc5551's 7475 (+167 B; +437 B minified, 20455 -> + // 20892) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). limit: floorCaps["signals: core floor (createSignal/Memo/Effect/Root/flush)"], capMinified: floorMinified["signals: core floor (createSignal/Memo/Effect/Root/flush)"], alias @@ -1044,8 +1052,16 @@ module.exports = [ // them their transaction instead of the committed screen. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // 2026-10-13. - limit: "14.94 KB", - capMinified: 45365, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 14.94 -> 15.16 KB, measured locally at 15145 B against `next` @ + // 3fc5551's 14958 (+187 B; +442 B minified, 45384 -> + // 45826) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "15.16 KB", + capMinified: 45826, alias }, { @@ -1321,8 +1337,16 @@ module.exports = [ // them their transaction instead of the committed screen. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // 2026-10-13. - limit: "9.77 KB", - capMinified: 27447, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 9.77 -> 9.96 KB, measured locally at 9943 B against `next` @ + // 3fc5551's 9771 (+172 B; +507 B minified, 27465 -> + // 27972) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "9.96 KB", + capMinified: 27972, alias }, { @@ -1335,8 +1359,16 @@ module.exports = [ // the truth helpers out of lanes.ts, this bundle carried all of lanes.js // (+4.8 KB minified / +1.6 KB brotli over the core floor). Measured at // 8.056 KB, minified 22195. - limit: "8.07 KB", - capMinified: 22195 + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 8.07 -> 8.26 KB, measured locally at 8241 B against `next` @ + // 3fc5551's 8056 (+185 B; +461 B minified, 22195 -> + // 22656) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "8.26 KB", + capMinified: 22656 }, { name: "app: render + one signal (the simple-app floor)", @@ -1564,6 +1596,14 @@ module.exports = [ // instead of the committed screen. Cap set at measured + 10 B rounded up to // 0.01 KB. Accepted by the maintainer 2026-10-13; it stays under 10 KB. The // cap is frozen again at 9.98 KB. + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 9.98 -> 10.16 KB, measured locally at 10150 B against `next` @ + // 3fc5551's 9975 (+175 B; +437 B minified, 27963 -> + // 28400) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). limit: floorCaps["app: render + one signal (the simple-app floor)"], capMinified: floorMinified["app: render + one signal (the simple-app floor)"], alias @@ -1932,6 +1972,14 @@ module.exports = [ // whose own flush joins that hold back into it. Cap set at measured + // 10 B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14 // (floor-caps.json; the floors unchanged, 0 B minified). + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 18.09 -> 18.27 KB, measured locally at 18253 B against `next` @ + // 3fc5551's 18092 (+161 B; +445 B minified, 53371 -> + // 53816) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). limit: floorCaps["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], capMinified: floorMinified["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], alias @@ -2452,8 +2500,16 @@ module.exports = [ // branch of hydratedCreateLoadingBoundary skips the fallback when `$df` // already swapped the content in. Cap set at measured + 10 B rounded up // to 0.01 KB. Accepted by the maintainer 2026-10-15. - limit: "29.69 KB", - capMinified: 93753, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 29.69 -> 29.93 KB, measured locally at 29912 B against `next` @ + // 3fc5551's 29692 (+220 B; +444 B minified, 93758 -> + // 94202) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "29.93 KB", + capMinified: 94202, alias }, { @@ -2724,8 +2780,16 @@ module.exports = [ // whose own flush joins that hold back into it. Cap set at measured + // 10 B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14 // (the floors unchanged, 0 B minified). - limit: "13.20 KB", - capMinified: 37415, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 13.20 -> 13.36 KB, measured locally at 13349 B against `next` @ + // 3fc5551's 13192 (+157 B; +439 B minified, 37346 -> + // 37785) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "13.36 KB", + capMinified: 37785, alias }, { @@ -3033,8 +3097,16 @@ module.exports = [ // whose own flush joins that hold back into it. Cap set at measured + // 10 B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14 // (the floors unchanged, 0 B minified). - limit: "14.79 KB", - capMinified: 41943, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 14.79 -> 14.95 KB, measured locally at 14933 B against `next` @ + // 3fc5551's 14761 (+172 B; +439 B minified, 41876 -> + // 42315) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "14.95 KB", + capMinified: 42315, alias: observeAlias }, { @@ -3501,8 +3573,16 @@ module.exports = [ // whose own flush joins that hold back into it. Cap set at measured + // 10 B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14 // (the floors unchanged, 0 B minified). - limit: "29.38 KB", - capMinified: 88330, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 29.38 -> 29.59 KB, measured locally at 29571 B against `next` @ + // 3fc5551's 29377 (+194 B; +443 B minified, 88264 -> + // 88707) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "29.59 KB", + capMinified: 88707, alias: observeAlias }, // Compiled-template scenarios (2026-10-05): the four `app:` fixtures above @@ -3567,8 +3647,16 @@ module.exports = [ // them their transaction instead of the committed screen. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // 2026-10-13. - limit: "10.17 KB", - capMinified: 28475, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 10.17 -> 10.36 KB, measured locally at 10347 B against `next` @ + // 3fc5551's 10178 (+169 B; +438 B minified, 28493 -> + // 28931) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "10.36 KB", + capMinified: 28931, alias }, { @@ -3669,8 +3757,16 @@ module.exports = [ // whose own flush joins that hold back into it. Cap set at measured + // 10 B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14 // (the floors unchanged, 0 B minified). - limit: "25.92 KB", - capMinified: 81041, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 25.92 -> 26.08 KB, measured locally at 26061 B against `next` @ + // 3fc5551's 25901 (+160 B; +443 B minified, 80987 -> + // 81430) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "26.08 KB", + capMinified: 81430, alias }, { @@ -3797,8 +3893,16 @@ module.exports = [ // branch of hydratedCreateLoadingBoundary skips the fallback when `$df` // already swapped the content in. Cap set at measured + 10 B rounded up // to 0.01 KB. Accepted by the maintainer 2026-10-15. - limit: "31.89 KB", - capMinified: 101369, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 31.89 -> 31.99 KB, measured locally at 31972 B against `next` @ + // 3fc5551's 31867 (+105 B; +440 B minified, 101375 -> + // 101815) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "31.99 KB", + capMinified: 101815, alias }, { @@ -4566,6 +4670,14 @@ module.exports = [ // minified). A select value is reapplied when its options arrive, // including multiple. Cap set to that brotli, not above it. Hello world // on this merge is 9,939 B, under the 9.94 KB floor and under 10 KB. + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 34.33 -> 34.80 KB, measured locally at 34781 B against `next` @ + // 3fc5551's 34617 (+164 B; +432 B minified, 106701 -> + // 107133) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -4821,6 +4933,14 @@ module.exports = [ // merged with next @ 617d19511 and measured at 38,146 B (118,990 B // minified). A select value is reapplied when its options arrive, // including multiple. Cap set to that brotli, not above it. + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 38.15 -> 38.60 KB, measured locally at 38590 B against `next` @ + // 3fc5551's 38429 (+161 B; +503 B minified, 118923 -> + // 119426) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5131,8 +5251,16 @@ module.exports = [ // #3928 (2026-10-09): a select value is reapplied when its options arrive, // including multiple. Merged with next @ 617d19511: 41,794 B brotli // (130,591 B minified). Cap set to that brotli, not above it. - limit: "41.794 KB", - capMinified: 130591, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 41.79 -> 42.23 KB, measured locally at 42217 B against `next` @ + // 3fc5551's 42020 (+197 B; +434 B minified, 130582 -> + // 131016) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "42.23 KB", + capMinified: 131016, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5225,8 +5353,16 @@ module.exports = [ // #3928 (2026-10-09): a select value is reapplied when its options arrive, // including multiple. Merged with next @ 617d19511: 47,493 B brotli // (144,124 B minified). Cap set to that brotli, not above it. - limit: "47.493 KB", - capMinified: 144124, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 47.49 -> 48.01 KB, measured locally at 47993 B against `next` @ + // 3fc5551's 47729 (+264 B; +519 B minified, 144119 -> + // 144638) — the signals core: an error a held node finds is staged like a + // value and published at its frame's commit, a shown error stays the + // display through its retry, `latest` throws a staged error. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // 2026-10-15 (golf first, then raise by what is left). + limit: "48.01 KB", + capMinified: 144638, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }