diff --git a/.changeset/held-error-waits-for-frame.md b/.changeset/held-error-waits-for-frame.md new file mode 100644 index 000000000..c02066ccc --- /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 03f1240eb..c9e5c96c6 100644 --- a/packages/signals/docs/OPEN-QUESTIONS.md +++ b/packages/signals/docs/OPEN-QUESTIONS.md @@ -2,7 +2,24 @@ **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-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-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. D-41 (ruled) does not depend on it; decide on its own. + +**Answer format that is enough:** `Q-7 A` — or the line you prefer. --- @@ -23,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 0d908637c..3ad13ab09 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -334,6 +334,24 @@ An `` flipping to its fallback is a boundary going back to its fallback --- +### 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-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`. + +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. + +**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. + +--- + ## F. Declared lag (F-1, F-2, F-7, F-9) ### D-40. A deferred source lags its readers and never leads them diff --git a/packages/signals/docs/RULES-FUNDAMENTALS.md b/packages/signals/docs/RULES-FUNDAMENTALS.md index 2226570e6..957ca9f92 100644 --- a/packages/signals/docs/RULES-FUNDAMENTALS.md +++ b/packages/signals/docs/RULES-FUNDAMENTALS.md @@ -122,9 +122,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-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. **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-41. **Scenarios:** S-38, S-42, S-43. --- diff --git a/packages/signals/docs/SCENARIOS.md b/packages/signals/docs/SCENARIOS.md index 40dc4ecdc..757912d23 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-39, D-41) ### S-36. A declared first paint is loading, not pending — D-30 (A27) @@ -815,6 +815,42 @@ const data = createMemo(() => fetchOrFail(count())); // rejects for every co `` never catches a pending: `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning (A6). +### S-42. A held rejection reveals with its frame — D-41 (#3886) + +_Pinned: `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 — 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-43. A held recovery keeps the shown error; recovering to the last good value is a change — D-41, D-39 (#3886) + +_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-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) | | | | | + ## F. Declared lag (D-40) ### S-41. Independent panels over one selection: the write shows now, each panel lags to its own landing — D-40 diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 9ba0eb187..f448a9c91 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, @@ -27,6 +30,7 @@ import { cleanup } from "./owner.js"; import { clock, flush, + flushTransaction, globalQueue, GlobalQueue, joinFuture, @@ -167,6 +171,40 @@ 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-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 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 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 && flushTransaction) + ) || + 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) 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); + el._config |= CONFIG_STAGED | CONFIG_STAGED_ERROR; + // Readers parked on the flight re-run and derive from the staging. + settlePendingSource(el); + return true; +} + export function settleErroredDependents(el: Computed, error: any): void { let scheduled = false; const visited = new Set>(); @@ -428,7 +466,11 @@ export function handleAsync( el._time = clock; 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. @@ -460,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 @@ -813,6 +858,8 @@ 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 diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 5ad51989c..c918d537d 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-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 << 21; /** 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 51c9fb4c4..332ebd56e 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, @@ -30,6 +31,7 @@ import { CONFIG_HELD_FRAME, CONFIG_LAGS, CAUGHT_REARMED, + CONFIG_STAGED_ERROR, CONFIG_SYNC, CONFIG_TRANSPARENT, defaultContext, @@ -368,6 +370,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-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 // re-ask does not launder (a poll during a quiet confirm stays quiet). @@ -446,11 +450,15 @@ export function recompute(el: Computed, create: boolean = false): void { // retry can actually run. parkLoadingWindow(el, e as NotReadyError); } 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 @@ -458,8 +466,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); @@ -530,13 +539,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 @@ -566,7 +575,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 || + !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 @@ -992,7 +1004,8 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { _flushedAt: -1, _q: 0, _lane: NOT_PENDING, - _marks: 0 + _marks: 0, + _shownError: undefined }); } @@ -1861,6 +1874,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) throw el._x._shownError; const value = serve(el, c as Computed | null) as T; if ( !c && @@ -1915,6 +1931,8 @@ export function serve(el: Signal | Computed, c: Computed | null): ) return el._value; stagedRead(c!, el); + // D-41: the staged answer is an error (`stageError`). + if (el._config & CONFIG_STAGED_ERROR) throw el._pendingValue; return el._pendingValue; } @@ -2023,8 +2041,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-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) || + !!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 15db1cf78..06f94ea67 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 { 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"; @@ -1175,10 +1177,29 @@ 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 err = n._config & CONFIG_STAGED_ERROR; + // An answer commits: the error it replaces leaves the display (D-41). + if (!inFlight && c._x) c._x._shownError = undefined; 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) { + : 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`). 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; 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 0d6d2b225..6074adfb2 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 793b69d66..13dbe08be 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._x!._shownError || + !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-41). */ +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; } @@ -403,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) @@ -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/src/signals.ts b/packages/signals/src/signals.ts index cbdac8af8..058190272 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; // A truth reader waits out a `createDeferred` flight its value derives @@ -801,6 +803,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 new file mode 100644 index 000000000..30ed90e49 --- /dev/null +++ b/packages/signals/tests/error-is-an-answer.test.ts @@ -0,0 +1,270 @@ +/** + * 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 + * 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, held or not. + * + * 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, + 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-42: 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-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"; + 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 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); + 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(); + } +}); + +// 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 000000000..faba616ac --- /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(); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 3f248bbdc..7b418ca9f 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.50 KB", - "minified": 20469 + "cap": "7.67 KB", + "minified": 20906 }, "app: render + one signal (the simple-app floor)": { - "cap": "10.01 KB", - "minified": 27977 + "cap": "10.17 KB", + "minified": 28414 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { - "cap": "18.11 KB", - "minified": 53386 + "cap": "18.29 KB", + "minified": 53831 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "34.334 KB", - "minified": 106814 + "cap": "34.89 KB", + "minified": 107246 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.146 KB", - "minified": 119037 + "cap": "38.70 KB", + "minified": 119540 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 0c47e9ca0..dc8f50742 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -584,6 +584,14 @@ module.exports = [ // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer 2026-10-14. The cap is frozen // again at 7.50 KB. + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 7.50 -> 7.67 KB, measured locally at 7656 B against `next` @ + // 4a1ed17's 7484 (+172 B; +437 B minified, 20469 -> + // 20906) — 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 @@ -1059,8 +1067,16 @@ module.exports = [ // #4073's seedless derived stores, each inside the 20 B allowance // alone, together pass it. Cap set at measured + 10 B rounded up to // 0.01 KB. Accepted by the maintainer (2026-10-14). - limit: "15.00 KB", - capMinified: 45395, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 15.00 -> 15.16 KB, measured locally at 15146 B against `next` @ + // 4a1ed17's 14993 (+153 B; +442 B minified, 45401 -> + // 45843) — 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: 45843, alias }, { @@ -1343,8 +1359,16 @@ module.exports = [ // `REACTIVE_REASK` mask (2 B); `next` already sat 18-19 B over the // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer 2026-10-14. - limit: "9.79 KB", - capMinified: 27480, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 9.79 -> 9.98 KB, measured locally at 9968 B against `next` @ + // 4a1ed17's 9781 (+187 B; +507 B minified, 27480 -> + // 27987) — 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.98 KB", + capMinified: 27987, alias }, { @@ -1362,8 +1386,16 @@ module.exports = [ // (#4067) grew the signals core this bundle shares (+60 B minified); no // lane code came back. Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer (2026-10-15, "Ratchet up"). - limit: "8.11 KB", - capMinified: 22255 + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 8.11 -> 8.29 KB, measured locally at 8279 B against `next` @ + // 4a1ed17's 8095 (+184 B; +461 B minified, 22255 -> + // 22716) — 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.29 KB", + capMinified: 22716 }, { name: "app: render + one signal (the simple-app floor)", @@ -1599,6 +1631,14 @@ module.exports = [ // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer 2026-10-14. Hello world stays // under 10 KB at 9,995 B. The cap is frozen again at 10.01 KB. + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 10.01 -> 10.17 KB, measured locally at 10155 B against `next` @ + // 4a1ed17's 9994 (+161 B; +437 B minified, 27977 -> + // 28414) — 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 @@ -1974,6 +2014,14 @@ module.exports = [ // branch, each inside the 20 B allowance alone, together pass it. // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the // maintainer (2026-10-14). + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 18.11 -> 18.29 KB, measured locally at 18280 B against `next` @ + // 4a1ed17's 18091 (+189 B; +445 B minified, 53386 -> + // 53831) — 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 @@ -2501,8 +2549,16 @@ module.exports = [ // branch, each inside the 20 B allowance alone, together pass it. // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the // maintainer (2026-10-14). - limit: "29.76 KB", - capMinified: 93770, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 29.76 -> 29.91 KB, measured locally at 29894 B against `next` @ + // 4a1ed17's 29721 (+173 B; +444 B minified, 93775 -> + // 94219) — 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.91 KB", + capMinified: 94219, alias }, { @@ -2773,8 +2829,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 13348 B against `next` @ + // 4a1ed17's 13194 (+154 B; +439 B minified, 37361 -> + // 37800) — 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: 37800, alias }, { @@ -3082,8 +3146,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 14935 B against `next` @ + // 4a1ed17's 14772 (+163 B; +439 B minified, 41891 -> + // 42330) — 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: 42330, alias: observeAlias }, { @@ -3550,8 +3622,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.56 KB, measured locally at 29546 B against `next` @ + // 4a1ed17's 29340 (+206 B; +443 B minified, 88279 -> + // 88722) — 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.56 KB", + capMinified: 88722, alias: observeAlias }, // Compiled-template scenarios (2026-10-05): the four `app:` fixtures above @@ -3623,8 +3703,16 @@ module.exports = [ // `REACTIVE_REASK` mask (2 B); `next` already sat 18-19 B over the // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer 2026-10-14. - limit: "10.21 KB", - capMinified: 28507, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 10.21 -> 10.36 KB, measured locally at 10341 B against `next` @ + // 4a1ed17's 10178 (+163 B; +438 B minified, 28507 -> + // 28945) — 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: 28945, alias }, { @@ -3725,8 +3813,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.12 KB, measured locally at 26104 B against `next` @ + // 4a1ed17's 25934 (+170 B; +443 B minified, 81005 -> + // 81448) — 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.12 KB", + capMinified: 81448, alias }, { @@ -3860,8 +3956,16 @@ module.exports = [ // branch, each inside the 20 B allowance alone, together pass it. // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the // maintainer (2026-10-14). - limit: "31.89 KB", - capMinified: 101387, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 31.89 -> 32.03 KB, measured locally at 32014 B against `next` @ + // 4a1ed17's 31891 (+123 B; +440 B minified, 101393 -> + // 101833) — 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: "32.03 KB", + capMinified: 101833, alias }, { @@ -4662,6 +4766,14 @@ module.exports = [ // busy 340-429 -> 214-339 ms/s at 100 rows / 10%; 400 rows no longer // saturates. Size-Exception: // recorded minified (floor-caps.json) 106,786 -> 106,814; brotli cap unchanged. Accepted by the maintainer (2026-10-15). + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 34.33 -> 34.89 KB, measured locally at 34875 B against `next` @ + // 4a1ed17's 34745 (+130 B; +432 B minified, 106814 -> + // 107246) — 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)"], @@ -4927,6 +5039,14 @@ module.exports = [ // busy 340-429 -> 214-339 ms/s at 100 rows / 10%; 400 rows no longer // saturates. Size-Exception: // recorded minified (floor-caps.json) 119,009 -> 119,037; brotli cap unchanged. Accepted by the maintainer (2026-10-15). + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 38.15 -> 38.70 KB, measured locally at 38684 B against `next` @ + // 4a1ed17's 38620 (+64 B; +503 B minified, 119037 -> + // 119540) — 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)"], @@ -5254,8 +5374,16 @@ module.exports = [ // busy 340-429 -> 214-339 ms/s at 100 rows / 10%; 400 rows no longer // saturates. Recorded // minified 130,667 -> 130,695; brotli cap unchanged. Accepted by the maintainer (2026-10-15). - limit: "41.794 KB", - capMinified: 130695, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 41.79 -> 42.22 KB, measured locally at 42206 B against `next` @ + // 4a1ed17's 42100 (+106 B; +434 B minified, 130695 -> + // 131129) — 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.22 KB", + capMinified: 131129, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5365,8 +5493,16 @@ module.exports = [ // busy 340-429 -> 214-339 ms/s at 100 rows / 10%; 400 rows no longer // saturates. Recorded // minified 144,205 -> 144,233; brotli cap unchanged. Accepted by the maintainer (2026-10-15). - limit: "47.493 KB", - capMinified: 144233, + // Size-Exception (a held error waits for its frame, D-41, #4085, 2026-10-15): + // 47.49 -> 48.05 KB, measured locally at 48036 B against `next` @ + // 4a1ed17's 47857 (+179 B; +519 B minified, 144233 -> + // 144752) — 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.05 KB", + capMinified: 144752, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }