diff --git a/.changeset/latest-held-error.md b/.changeset/latest-held-error.md new file mode 100644 index 000000000..757fa0c86 --- /dev/null +++ b/.changeset/latest-held-error.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Reveal and recover reactive error outcomes consistently, including ordinary outside reads through held rejection and recovery, `latest()` derivations, projection readers, snapshots and promise-delivery helpers. Published errors remain the answer through pending retry; a revealed failure ends the initial loadingValue window while preserving successful memo prev history. Share effect callback execution and projection failure notification. Release unobserved lazy readers after async comparator failures, and allow successful direct writes to replace writable derivations' errors. Diagnostic formatting failures no longer replace the original thrown value. diff --git a/packages/signals/design/error-outcomes.md b/packages/signals/design/error-outcomes.md new file mode 100644 index 000000000..874ff6acd --- /dev/null +++ b/packages/signals/design/error-outcomes.md @@ -0,0 +1,152 @@ +# Reactive outcome representation and publication + +Original audit target: `next` at `53ef0e69`, subsequently rebased and checked +against `bc51ada9` and rebased onto `d231b991`, plus this branch's outcome changes. This is a +source audit and regression study, not a proof of the scheduler or the complete +DOM/outside-read law. + +## Outcomes, frames and successful history + +An answer is a successful payload or a user failure. Pending is an availability +condition, not a failure. Each observing frame needs its own published answer, +separate from the proposed answer being derived. An Error fulfilled as data is +still data; any JavaScript value, including undefined, can be thrown. + +Successful payloads stay unboxed: + +| Storage | Meaning | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_value` | Published successful payload and memo's successful `prev` history. Retained across failures; does not by itself identify the published outcome. | +| `_pendingValue` | Proposed successful payload; NOT_PENDING means no payload staging. A projection can recover without staging a payload on its controlling computation. | +| `_statusFlags`, `_x._error` | Proposed availability/failure. STATUS_ERROR selects a source-tagged StatusError with the exact user cause; NotReadyError represents pending control flow. | +| CONFIG_COMMITTED_ERROR, `_x._committedError` | Published failure, independent of proposed status. Retained during retry and held recovery; replaced at the same commit gate as successful answers. | +| `_x._lane`, `_x._laneError` | Successful payload and published failure of an optimistic/derived lane. A written guess supplies its own successful answer while masking underlying truth. | +| `_x._snapshotValue`, CONFIG_SNAPSHOT_ERROR | Captured successful payload or tagged failure for snapshot scopes. The existing slot captures either outcome; no extra snapshot field is added. | +| `_x._flushed` | Successful staging retained for A28 mid-tick visibility, not an outcome discriminator. | +| `_x._inFlight`, `_pendingSources`, `_blocked` | Flight identity, outstanding origins and retry obligation. These remain separate from terminal outcomes. | +| effect `_prevValue` | Successful compute payload supplied to the success callback. The error callback has a separate arm. | + +The two new error references live in the lazily allocated extension, with one +shared shape. Healthy scalar signals do not allocate it just for this feature. +Computations still allocate no Result object per successful evaluation. This is +additional storage when an extension exists, not a memory-saving change. Five +isolated local V8 probes measured approximately 360 bytes per lazy memo without +an extension in both versions, and 512 → 528 bytes with one (including accessor +and array storage). These are local retained-heap measurements, not a guarantee +for other engines. + +## Read selection and publication + +Ordinary outside reads of the truth frame and children-forbidden observers +interpret the published outcome. Derivations interpret proposed outcomes; a proposed failure therefore +throws during derivation while its side-effect callback waits for reveal. +Render observers retain the existing frame/mount rules. Outcome selection follows +those rules rather than exposing the current working error before choosing a +frame. + +For an initialized truth-frame source held by a rendered async sibling: + +| Proposed answer | Published answer | Ordinary outside read | isPending | +| ---------------------------------------------------- | ------------------------------- | ------------------------ | --------------------------------------------------- | +| new failure | old success | old success | true | +| pending retry | failure | throws published failure | true, unless the existing quiet re-ask rule applies | +| successful recovery, even equal to last good payload | failure | throws published failure | true | +| different failure | old failure | throws old failure | true | +| frame revealed | newly published success/failure | new value or new failure | false | + +A born-held node without a published answer remains NotReady until reveal, +including when its first request has already rejected. Independent requests are +not globally entangled merely because both are initially loading. +For an async first pass over a hold, next's #3800 direction rule applies: +the first load does not keep the birth frame open. Its successful or failed +answer joins that frame only if it is still live at settlement; otherwise the +answer publishes independently. + +Verdict reads select a stale render reader's published frame before interpreting +proposed errors. All remaining failed proposals pass through one failure gate, +including born-held failures with no successful staging. A snapshot may capture +only a published answer: an uninitialized held memo's `_value` is a placeholder, +not a successful `undefined`. A first synchronous failure published directly at +creation also closes the loading seed window before a retry can run in the first +flush; successful seed/`prev` history remains separate from accessor outcomes. +Born-held creation retains an already published loading seed for both successful +and failed proposals. A queued user callback can precede the end of the creation +pass, when its frame membership becomes known; an unpublished user effect's +runner hands that first callback to the frame's existing queue while held. +This applies after recovery too. Initialized callbacks keep the selected +lane's reveal rules even when ordinary truth remains held; mount-time render +work retains its separate DOM-construction rules. + +`latest` selects the proposed view, including failures and successful recovery. +It retains next's existing optimistic/view behavior; this branch does not redefine +all ordinary/latest imperative visibility. In particular, an outside read of a +lane-backed accessor still uses next's proposed lane view, which can disagree +with held DOM. Errors now follow that same selected view instead of bypassing it. +This remains the previously accepted intentional inconsistency; it must not be +confused with fixing the truth-frame outside-read mismatch. A lane's shown failure survives its +own pending correction just like its shown successful payload. Dissolving a lane +transfers the shown outcome with the shown payload. A snapshot captures either +outcome; its boundary must wait on captured status rather than live source status. +Boundary plumbing itself is not a frozen source: its nested fallback/content +structure must be able to resume during hydration. Its source reads still use +captured outcomes. + +A `loadingValue` is a successful initial answer. A revealed failure ends its +initial seed window, just as a revealed success does. The seed can remain the last +successful `prev` passed into a retry, but retaining it for `prev` does not make it +resurface in accessor reads. Errors are never supplied as memo `prev` values. + +A direct write to an unheld writable memo supplies a successful proposal, even +if its derivation previously failed. It clears the working terminal failure but +keeps the published failure until the proposal commits. During a pending retry, +the existing manual-write flag identifies the successful staged answer at that +commit: it can replace the published failure without claiming the outstanding +request has finished. Pending availability remains, and the request's eventual +answer still lands normally. This matches the existing successful-payload +control. A write into another frame's held derivation instead becomes successful +`prev` history for its re-derivation (A34); it does not publish a temporary manual +value. Regressions cover initial and later failures, same-payload writes, retry +pendingness, and held recovery. + +Async comparator failures use the same flight-error handler as rejection. Status +propagation removes dependents' pending entries; the error release sweep must +then retire lazy readers that lost their last subscriber during the request. +Calling only `notifyStatus` skipped that release and retained their cleanup and +source subscriptions indefinitely. Successful fulfillment and promise rejection +serve as paired observation-lifecycle controls. + +## Package audit map + +| Files | Responsibility | +| ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `core/core.ts`, `core/async.ts`, `core/types.ts`, `core/constants.ts` | Create, stage and select outcomes. Sync throws, promise/iterator rejection and comparator failure use the same origin/status protocol. Error recovery is an outcome change even when payload equality says unchanged. Born-held pending computations retain their frame for a first rejection. | +| `core/scheduler.ts`, `core/effect.ts` | Publish terminal outcomes at commit, retaining published failures during pending retries. Both effect callback arms share scope, exception containment and final bookkeeping; error handlers retain explicit cleanup control. | +| `core/lanes.ts`, `core/verdict.ts` | Select an optimistic/verdict view before interpreting failure; retain the shown lane failure separately. isPending compares outcomes as well as successful payloads, including projection recovery without a payload staging. | +| `boundaries.ts`, `reveal.ts` | Convert failure/unavailability into fallback output, preserving user cause. Only NotReadyError suspends Loading. Keep the tree subscription while waiting on a fallback, and prune snapshot readers against captured outcomes. Mount visibility and reveal-order machinery otherwise retain their existing contracts. | +| `store/store.ts`, `store/projection.ts`, `store/target.ts`, `store/types.ts` | Leaves carry field payloads; the family controller carries failure. Its read gate applies through held recovery as well as current failure. Sync and async failure share family notification so existing leaf readers learn both failure and recovery. | +| `store/optimistic.ts`, `store/reconcile.ts`, `store/storePath.ts`, `store/utils.ts`, `store/affects.ts`, `store/index.ts` | Payload/backing/arrangement and overlay operations. Failure stays at the family/controller gate rather than replacing every leaf with an error object. | +| `signals.ts`, `core/action.ts` | resolve/until/refresh apply the foreign-frame delivery gate to fulfillment and rejection. A waiter inside its own action can still settle without waiting on itself. Abort/timeout/action errors retain separate control channels. | +| `affects.ts` | Reachability/availability marks. A revealed failure is settled although its accessor throws; a held outcome transition can remain pending. | +| `map.ts`, `flatten.ts` | Collection/content derivations propagate through core reads rather than manufacturing replacement success from failure. | +| `core/error.ts`, diagnostic/attribution helpers | Preserve exact cause and origin. Best-effort formatting contains hostile coercion without replacing the original thrown value. Stack capture retains upstream behavior. | + +## Regressions and limits + +Regressions cover held callbacks, outside reads in both directions, different +successive failures, same-payload recovery, falsy errors, latest derivations, +projection readers, snapshot success/failure, born-held first rejection, +loadingValue/prev separation, boundaries, cancellation, fulfilled Error data and +promise-delivery timing. Browser controls exercise real compiled JSX and connected +DOM in development and production. + +The audit also found synchronous projection notification, foreign-frame rejection +delivery and hostile diagnostic formatting gaps on the unmodified audit target. +They are fixed in this branch along with the original held-latest and callback +recovery failures. The earlier intentional held latest/isPending imperative-view +differences are not claimed fixed by this error-outcome change. + +No performance or memory improvement is claimed. Earlier node-field packing +prototypes were rejected because of slower healthy-update medians or larger +bundles. Successful-payload history and availability remain explicit; boxing every +success or replacing `_value` with an exception would add healthy-path cost or +lose required history. These regressions are not new Bend/kernel proofs. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 8687e305a..89a31a713 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -63,7 +63,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:219` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:79` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:147` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | +| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:147` | async.ts×1 constants.ts×1 core.ts×5 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | | A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:406` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | | A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:413` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | | A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:227` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … | @@ -72,11 +72,11 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:307` | projection.ts×1 | derived-presence-async-3726.test.ts×2 uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:299` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:93` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 issue-3800-repro.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:93` | constants.ts×1 core.ts×11 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 issue-3800-repro.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 writable-error-outcomes.test.ts×1 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | | A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×1 action.ts×1 async.ts×1 constants.ts×1 core.ts×13 effect.ts×1 lanes.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:255` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:129` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | -| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:137` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:137` | core.ts×3 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:271` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×4 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | | A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:281` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 631f60627..28970151e 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -41,6 +41,8 @@ */ import { CONFIG_REDERIVE, + CONFIG_IN_SNAPSHOT_SCOPE, + CONFIG_SNAPSHOT_ERROR, CONFIG_HELD, CONFIG_OVERRIDE, CONFIG_VERDICT, @@ -55,7 +57,15 @@ import { STATUS_PENDING, STATUS_UNINITIALIZED } from "./core/constants.js"; -import { computed, read, recompute, runWithOwner, setSignal, signal } from "./core/core.js"; +import { + computed, + read, + recompute, + runWithOwner, + setSignal, + signal, + snapshotCaptureActive +} from "./core/core.js"; import { emitDiagnostic, reportDiagnostic } from "./core/dev.js"; import { NotReadyError, unwrapStatusError } from "./core/error.js"; import { reportClientError } from "./core/error-hooks.js"; @@ -124,6 +134,7 @@ interface Boundary { /** Context key: the nearest boundary of a node, inherited at creation. */ const BOUNDARY = Symbol(__DEV__ ? "boundary" : ""); +const BOUNDARY_OPTIONS = { _noSnapshot: true }; /** Context key: the reveal controller a Loading boundary created here is a * slot of (reveal.ts); a boundary clears it for its content — only direct * children are slots. */ @@ -215,7 +226,7 @@ function catchStatus(node: Computed, flags: number, error?: unknown): boole if (collecting.size !== 0) for (let b = boundaryOf(node); b !== undefined; b = b._parent ?? undefined) if ( - !(node._statusFlags & unsettled(b)) && + !(readerStatus(b, node) & unsettled(b)) && b._readers.delete(node) && b._readers.size === 0 ) { @@ -292,11 +303,22 @@ function release(b: Boundary): void { const unsettled = (b: Boundary): number => b._type === STATUS_ERROR ? STATUS_ERROR | STATUS_PENDING : STATUS_PENDING; +/** A frozen reader waits on its captured outcome, not the live source. */ +function readerStatus(b: Boundary, node: Computed): number { + if ( + snapshotCaptureActive && + b._tree?._config & CONFIG_IN_SNAPSHOT_SCOPE && + node._x?._snapshotValue !== undefined + ) + return node._config & CONFIG_SNAPSHOT_ERROR ? STATUS_ERROR : 0; + return node._statusFlags; +} + export function prune(b: Boundary, pass: boolean): number { const mask = unsettled(b); for (const r of b._readers) { if (r._flags & REACTIVE_DISPOSED) b._readers.delete(r); - else if (!(r._statusFlags & mask)) { + else if (!(readerStatus(b, r) & mask)) { if (r !== b._tree && (r._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) { // Landed, held: the content is that transaction's — the output's // pass enters it and the reveal lands with the reader's run. The @@ -539,10 +561,15 @@ function createBoundary( if (revealHooks !== null) context[REVEAL] = null; owner._context = context; const tree = runWithOwner(owner, () => { - const c = __OBSERVE__ ? computed(fn, { name: "children" }) : computed(fn); + // Like the output below, these are boundary structure: a nested Loading + // fallback can resume during hydration. Freeze the source outcomes they + // read, not an intermediate fallback returned by boundary plumbing. + const c = __OBSERVE__ + ? computed(fn, { name: "children", _noSnapshot: true }) + : computed(fn, BOUNDARY_OPTIONS); return __OBSERVE__ - ? computed(() => flatten(read(c)), { name: "boundary" }) - : computed(() => flatten(read(c))); + ? computed(() => flatten(read(c)), { name: "boundary", _noSnapshot: true }) + : computed(() => flatten(read(c)), BOUNDARY_OPTIONS); }); b._tree = tree; // A slot of the reveal order in its context (reveal.ts): gated until the @@ -615,7 +642,13 @@ function createBoundary( return fallback(b); // Readers under it still unready: the fallback, the tree untouched. // The seam re-derives this pass when they settle. - if (prune(b, true) !== 0) return fallback(b); + if (prune(b, true) !== 0) { + // The fallback still waits for this tree. Retain its subscription + // when a pending retry produced a successful inner Loading fallback; + // a later rejection must update the displayed error too. + if (type === STATUS_ERROR) link(tree, self); + return fallback(b); + } } let value: T; try { @@ -656,7 +689,7 @@ function createBoundary( // Boundary structure, not a user source: its value is fallback-or-content // and legitimately swaps mid-hydration (resume), so it must never be // frozen by snapshot capture. - __OBSERVE__ ? { name: "value", _noSnapshot: true } : { _noSnapshot: true } + __OBSERVE__ ? { name: "value", _noSnapshot: true } : BOUNDARY_OPTIONS ); output._config |= CONFIG_REDERIVE; b._output = output; diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 88a944301..a1560221e 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -4,9 +4,14 @@ import { CONFIG_GUESS, CONFIG_OVERRIDE, CONFIG_HELD, + CONFIG_COMMITTED_ERROR, + CONFIG_HAS_SNAPSHOT, + CONFIG_IN_SNAPSHOT_SCOPE, + REACTIVE_SNAPSHOT_STALE, CONFIG_SYNC, CONFIG_VERDICT, EFFECT_TRACKED, + EFFECT_RENDER, EFFECT_USER, NOT_PENDING, REACTIVE_DIRTY, @@ -17,7 +22,14 @@ import { STATUS_UNINITIALIZED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; -import { context, setSignal, untrack, ext, statusNotifierOf } from "./core.js"; +import { + context, + setSignal, + untrack, + ext, + statusNotifierOf, + snapshotCaptureActive +} from "./core.js"; import { devTrackHeldPending } from "./invariants.js"; import { emitDiagnostic, reportDiagnostic, watchAsyncTail } from "./dev.js"; import { NotReadyError, StatusError } from "./error.js"; @@ -137,51 +149,28 @@ function releaseIfSettledUnobserved(node: Computed): void { unobserved(node); } -// Error-path sweep: notifyStatus(STATUS_ERROR) clears dependents' pending -// sources through its own recursion (no per-node settle callback), so after -// the propagation completes, walk the same graph for stranded lazy nodes. -// Collect-then-release so unobserved() never unlinks under the walk. -export function releaseSettledDependents(el: Computed): void { +// The error-release and unchanged-payload recovery sweeps share the same +// deduplicated graph. `error` is the stored wrapper, never the raw user cause; +// even a thrown undefined has a truthy StatusError. No error means release. +// Identity holders retry even below an intermediate that has cleared its error. +// Collect before release so unobserved() never unlinks under the walk. +export function settleDependents(el: Computed, error?: unknown): void { let candidates: Computed[] | undefined; - const visited = new Set>(); - const visit = (node: Computed) => { - if (visited.has(node)) return; - visited.add(node); - if (!node._subs && node._config & CONFIG_AUTO_DISPOSE) (candidates ??= []).push(node); - forEachDependent(node, visit); - }; - forEachDependent(el, visit); - if (candidates) for (const node of candidates) releaseIfSettledUnobserved(node); -} - -// Error-dimension twin of settlePendingSource's blocked re-enqueue (#2949): -// a node in STATUS_ERROR that recovers by recomputing to an UNCHANGED value -// fires no value notification — the recovery is completely silent. But a -// dependent that re-ran during the error window consumed its dirty flag and -// committed nothing (the fresh sibling values it read were absorbed into an -// errored run), so its committed value is stale. The propagated error is one -// object identity down the whole dependent tree, and holding it is exactly -// the "blocked on this error" marker — re-enqueue those holders so they -// re-run: fresh values commit and flow, and a dependent with another -// still-broken source simply re-errors. Pending recovery uses -// settlePendingSource to clear inherited status and retry blocked readers. -// Walks the full dependent graph -// (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). -export function settleErroredDependents(el: Computed, error: any): void { let scheduled = false; const visited = new Set>(); const visit = (node: Computed) => { if (visited.has(node)) return; visited.add(node); - if (node._x?._error === error) { - enqueueSub(node); - scheduled = true; - } + if (error) { + if (node._x?._error === error) { + enqueueSub(node); + scheduled = true; + } + } else if (!node._subs && node._config & CONFIG_AUTO_DISPOSE) (candidates ??= []).push(node); forEachDependent(node, visit); }; forEachDependent(el, visit); + if (candidates) for (const node of candidates) releaseIfSettledUnobserved(node); if (scheduled) schedule(); } @@ -326,6 +315,13 @@ export function releaseFlightTeardown(el: Computed): void { } } +/** A terminal first answer joins its birth frame only while that frame lives. */ +function landFirstOutcome(el: Computed, uninitialized: number | boolean): void { + let t = el._x!._bornIn; + el._x!._bornIn = null; + if (uninitialized && t && (t = liveTx(t))) holdNode(el, t); +} + export function handleAsync( el: Computed, result: T | PromiseLike | AsyncIterable, @@ -430,6 +426,10 @@ export function handleAsync( el._time = clock; return; } + // A failed first answer follows #3800's successful-landing rule: join + // the birth frame if it is still live, without having held that frame + // open while this first request was pending. + if (!stillPending) landFirstOutcome(el, el._statusFlags & STATUS_UNINITIALIZED); 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 @@ -439,7 +439,7 @@ export function handleAsync( // A real error settles derivatively-pending dependents (notifyStatus // cleared their pending sources), so stranded lazy ones release here — // the error twin of settlePendingSource's release (#2934). - if (!stillPending) releaseSettledDependents(el); + if (!stillPending) settleDependents(el); onError?.(error, stillPending); }; @@ -468,15 +468,15 @@ export function handleAsync( // lane-routed landing (derived override, lane effect queue) went with // the optimistic engine; every landing is the plain setSignal. A first // answer born into a hold still live lands into it (A29, #3800). - let t = el._x!._bornIn; - el._x!._bornIn = null; - if (wasUninitialized && t && (t = liveTx(t))) holdNode(el, t); + landFirstOutcome(el, wasUninitialized); try { setSignal(el, () => value); } catch (e) { // Same containment as above: setSignal's comparator throw is the only // pre-commit failure here, and there is no user callsite to throw to. - notifyStatus(el, STATUS_ERROR, e); + // Release stranded lazy readers exactly as for a rejected flight; + // status propagation already removed their pending-source entries. + handleError(e); } // Attribution hook: this path landed through setSignal, whose write // hook already saw any committed change — direct=false lets the engine @@ -745,6 +745,7 @@ export function handleAsync( } export function clearStatus(el: Computed, clearUninitialized: boolean = false): void { + if (el._config & CONFIG_COMMITTED_ERROR) queuePendingNode(el); if (el._x?._pendingSources) clearPendingSources(el); if (el._x?._blocked) if (el._x !== null) el._x._blocked = false; el._statusFlags = clearUninitialized ? 0 : el._statusFlags & STATUS_UNINITIALIZED; @@ -807,7 +808,21 @@ export function notifyStatus( // (CARVE 2: the lane assignment went with the engine.) if (!blockStatus) { + // An error is a settled answer of the held computation, just like a + // fulfilled value. Resume its frame before queuing the effect's error + // arm; clearing pending sources otherwise loses the settle walk's join. + if ( + status === STATUS_ERROR && + passLane === null && + (el._config & (CONFIG_HELD | CONFIG_OVERRIDE | CONFIG_VERDICT)) === CONFIG_HELD && + (el as any)._type !== EFFECT_RENDER + ) + joinFuture(txOf(el)); if (status === STATUS_PENDING && pendingSource) { + // Recovering to the last good value still changes the outcome from + // error to value. Retry readers at settlement even if equality skips + // the value notification and propagation prevented a pending read. + if (el._statusFlags & STATUS_ERROR) ext(el)._blocked = true; addPendingSource(el, pendingSource); el._statusFlags = STATUS_PENDING | (el._statusFlags & STATUS_UNINITIALIZED); // Preserve the current source on this propagation so readers park on @@ -815,9 +830,12 @@ export function notifyStatus( setPendingError(el, pendingSource, error); } else { clearPendingSources(el); - el._statusFlags = - status | (status !== STATUS_ERROR ? el._statusFlags & STATUS_UNINITIALIZED : 0); + el._statusFlags = status | (el._statusFlags & STATUS_UNINITIALIZED); ext(el)._error = error; + if (status === STATUS_ERROR) { + queuePendingNode(el); + schedule(); + } } } @@ -863,6 +881,17 @@ export function propagateStatus( const pendingSource = status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined; forEachDependent(el, (sub, link) => { + // Snapshot readers derive from the captured outcome, not a live status + // propagation. Outcome changes replay when their scope is released. + if ( + snapshotCaptureActive && + el._config & CONFIG_HAS_SNAPSHOT && + el._x?._snapshotValue !== undefined && + sub._config & CONFIG_IN_SNAPSHOT_SCOPE + ) { + sub._flags |= REACTIVE_SNAPSHOT_STALE; + return; + } sub._time = clock; // A pending mark on a kept-tail link re-derives the subscriber instead of // marking it (A30, #3494 review; fuzzer latest-1 #2141; #3519 review). diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 2f18f98ea..614d387c9 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -70,6 +70,11 @@ export const REACTIVE_SCREEN_READ = 1 << 18; export const REACTIVE_PROBE_UNANSWERED = 1 << 19; // Static configuration bits packed into Owner/Computed/Signal _config. +/** The published frame ended in failure. The cause lives in the cold + * extension; current computation status describes the proposed frame. */ +export const CONFIG_COMMITTED_ERROR = 1 << 19; +/** The snapshot slot contains a failure rather than a successful payload. */ +export const CONFIG_SNAPSHOT_ERROR = 1 << 20; export const CONFIG_OWNED_WRITE = 1 << 0; export const CONFIG_NO_SNAPSHOT = 1 << 1; export const CONFIG_TRANSPARENT = 1 << 2; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index cd737912d..0ed54ecd6 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -4,7 +4,7 @@ import { notifyStatus, parkLoadingWindow, releaseFlightTeardown, - settleErroredDependents, + settleDependents, settlePendingSource } from "./async.js"; import { @@ -15,6 +15,8 @@ import { CONFIG_GUESS, CONFIG_VERDICT, CONFIG_HELD, + CONFIG_COMMITTED_ERROR, + CONFIG_SNAPSHOT_ERROR, CONFIG_IN_SNAPSHOT_SCOPE, CONFIG_INPUTS_PUBLISHED, CONFIG_OVERRIDE, @@ -89,6 +91,7 @@ import { cleanup, disposeChildren, inheritId, linkChild, markDisposal } from "./ import { notifyEpoch, bumpNotifyEpoch, + commitStatus, clock, deferZombie, dirtyQueue, @@ -243,6 +246,7 @@ export function clearSnapshots(): void { // object to dictionary mode for every later read of every field. const x = source._x; if (x != null) x._snapshotValue = undefined; + source._config &= ~CONFIG_SNAPSHOT_ERROR; } snapshotSources = null; } @@ -549,6 +553,9 @@ export function recompute(el: Computed, create: boolean = false): void { try { valueChanged = (!isEffect && wasUninitialized) || !el._equals || !el._equals(compareValue, value); + // Recovery changes the outcome even when its successful payload matches + // the last good value. Give readers a proposal to derive from. + valueChanged ||= !!(el._config & CONFIG_COMMITTED_ERROR) && el._pendingValue === NOT_PENDING; } 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 @@ -671,7 +678,7 @@ export function recompute(el: Computed, create: boolean = false): void { // (`commitPendingNode` initializes it) — every reader of it // derives from the future (`read`), an untracked one throws // (A19 exc. 1). - el._statusFlags |= STATUS_UNINITIALIZED; + if (wasUninitialized) el._statusFlags |= STATUS_UNINITIALIZED; } if (__DEV__) devTrackHeldPending(el); } @@ -693,7 +700,7 @@ export function recompute(el: Computed, create: boolean = false): void { // recoveries ride insertSubs above; a comparator throw re-errored the node // (el._x?._error re-set), so this only runs on a genuinely clean recovery. if (!valueChanged && !el._x?._error) { - if (outgoingError !== undefined) settleErroredDependents(el, outgoingError); + if (outgoingError !== undefined) settleDependents(el, outgoingError); // Self-registration (this node's own superseded flight) is the #3181 // sweep's business below — retiring it here too would walk twice. if (outgoingPendingSources) @@ -751,6 +758,19 @@ export function recompute(el: Computed, create: boolean = false): void { flushTransaction !== null || passTx !== null || (el._config & CONFIG_HELD) !== 0, el._pendingValue !== NOT_PENDING ); + // An initial failed outcome has the same publication posture as an + // initial successful answer: direct unless born into a held frame. + if (create && el._statusFlags & STATUS_ERROR) { + if ( + (flushTransaction !== null || passTx !== null) && + (joined || + (prevLane && el._flags & REACTIVE_STAGED_READ) || + (creatorPass(oldcontext)?._flags ?? 0) & REACTIVE_JOINED) + ) { + holdNode(el, (flushTransaction ?? passTx)!); + if (wasUninitialized) el._statusFlags |= STATUS_UNINITIALIZED; + } else commitStatus(el); + } // A staged value, a parked frame (L2: the commit retires it), or status the // commit sweep must settle (a pending or uninitialized pass), queues the // node for this flush's commit. A first pass queues only when pending or @@ -759,7 +779,7 @@ export function recompute(el: Computed, create: boolean = false): void { if ( el._pendingValue !== NOT_PENDING || (el._x !== null && (el._x._pendingFirstChild !== null || el._x._pendingDisposal !== null)) || - ((el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) !== 0 && + ((el._statusFlags & (STATUS_PENDING | STATUS_ERROR | STATUS_UNINITIALIZED)) !== 0 && (!create || (el._statusFlags & STATUS_PENDING) !== 0)) ) { el._config |= CONFIG_STAGED; @@ -949,6 +969,8 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { _inFlight: null, _flightTeardown: null, _error: undefined, + _committedError: undefined, + _laneError: undefined, _blocked: undefined, _pendingSources: undefined, _unobserved: undefined, @@ -1227,8 +1249,14 @@ function setupComputedNode(self: Computed, options: NodeOptions | undef GlobalQueue._wireExternalSource?.(self); !options?.lazy && recompute(self, true); if (snapshotCaptureActive && !options?.lazy) { - if (!(self._statusFlags & STATUS_PENDING) && !(self._config & CONFIG_NO_SNAPSHOT)) { - ext(self)._snapshotValue = self._value === undefined ? NO_SNAPSHOT : self._value; + if ( + !(self._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) && + !(self._config & CONFIG_NO_SNAPSHOT) + ) { + if (self._config & CONFIG_COMMITTED_ERROR) { + ext(self)._snapshotValue = self._x!._committedError; + self._config |= CONFIG_SNAPSHOT_ERROR; + } else ext(self)._snapshotValue = self._value === undefined ? NO_SNAPSHOT : self._value; self._config |= CONFIG_HAS_SNAPSHOT; snapshotSources!.add(self); } @@ -1516,21 +1544,10 @@ export function prepareComputed(comp: Computed, refresh: boolean): void // (enterStagedRead, stagedEntry / born held, underFreshLoadingBoundary) went // with the transactions. -/** - * Rule 1 (value selection): does this reader see a STAGED node's COMMITTED - * value? One implementation of the rule the fast paths (read's fast block) - * carry as their trivial ternary. In order: - * - no reader at all (an untracked read) — the committed frame; - * - nothing staged; - * - a children-forbidden reader (createTrackedEffect / onSettled: the frame, - * never the graph — A32). - * False means the reader derives from the staged value. - */ -export function readerSeesCommitted( - el: Signal | Computed, - c: Computed | null -): boolean { - return !!(!c || el._pendingValue === NOT_PENDING || c._config & CONFIG_CHILDREN_FORBIDDEN); +/** Interpret the selected published frame without consulting proposed status. */ +export function readCommitted(el: Signal | Computed): unknown { + if (el._config & CONFIG_COMMITTED_ERROR) throw el._x!._committedError; + return el._value; } /** A28 — set when a node is staged (queuePendingNode) OUTSIDE a flush; @@ -1709,6 +1726,38 @@ export function read(el: Signal | Computed): T { if (v !== NOT_PENDING) return v as T; } + // Select snapshot outcomes before interpreting live proposed status. + if (snapshotCaptureActive && c && c._config & CONFIG_IN_SNAPSHOT_SCOPE) { + const sv = el._x?._snapshotValue; + if (sv !== undefined) { + if (__DEV__ && strictRead) + warnStrictReadUntracked(strictRead, { + ownerId: c.id, + ownerName: (c as any)._name, + nodeName: (owner as any)?._name + }); + const snapshotError = el._config & CONFIG_SNAPSHOT_ERROR; + const currentError = owner._statusFlags & STATUS_ERROR ? el._x!._error : undefined; + const current = el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; + const snapshot = sv === NO_SNAPSHOT ? undefined : sv; + if (snapshotError ? currentError !== sv : currentError !== undefined || current !== snapshot) + (c as Computed)._flags |= REACTIVE_SNAPSHOT_STALE; + if (snapshotError) throw sv; + return snapshot as T; + } + } + + // Outside and children-forbidden reads observe the published outcome. + // A derivation still observes the proposed outcome, including failures. + const committedOutcome = committed || !c || !!(c._config & CONFIG_CHILDREN_FORBIDDEN); + if (committedOutcome && el._config & CONFIG_COMMITTED_ERROR) readCommitted(el); + if ( + committedOutcome && + owner._statusFlags & STATUS_UNINITIALIZED && + owner._statusFlags & STATUS_ERROR + ) + throw new NotReadyError(owner); + // A stale reader of a held flight (rule 3, A15's reveal corollary) is served // the committed value — coherent with the frame, whose inputs are the // committed ones too — and does not go pending on it. So is lane work @@ -1774,7 +1823,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). - if ((owner as Computed)._fn && (owner as Computed)._statusFlags & STATUS_ERROR) { + if (computed._fn && owner._statusFlags & STATUS_ERROR && !committedOutcome) { // 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 @@ -1784,9 +1833,8 @@ export function read(el: Signal | Computed): T { } else throw (owner as Computed)._x?._error; } - // Ahead of the snapshot serve below: a component body's direct read is - // wrong in the same way whether the pass is hydrating or not, and the - // hydration pass is the console nobody is watching (#3675). + // A component body's direct read needs the same strict-read diagnostic + // during hydration as in an ordinary pass (#3675). if (__DEV__ && strictRead) warnStrictReadUntracked(strictRead, { ownerId: c?.id, @@ -1794,19 +1842,9 @@ export function read(el: Signal | Computed): T { nodeName: (owner as any)?._name }); - if (snapshotCaptureActive && c && (c as Computed)._config & CONFIG_IN_SNAPSHOT_SCOPE) { - const sv = el._x?._snapshotValue; - if (sv !== undefined) { - const snapshot = sv === NO_SNAPSHOT ? undefined : sv; - const current = el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; - if (current !== snapshot) (c as Computed)._flags |= REACTIVE_SNAPSHOT_STALE; - return snapshot as T; - } - } - - if (committed) return el._value as T; - const value = serve(el, c as Computed | null) as T; + const value = serve(el, c as Computed | null, committed) as T; if ( + !committed && !c && typeof computed._fn === "function" && el._config & CONFIG_AUTO_DISPOSE && @@ -1825,41 +1863,46 @@ export function read(el: Signal | Computed): T { return value; } -/** - * Rule 1, the one slow implementation (DESIGN-CONSOLIDATION move 3b, step - * 6c): the value a reader `c` (null = untracked, no pass) is served from - * `el`. Called by read()'s slow tail; the fast path (read's fast block) keeps - * its trivial ternary by design (perf, see the doc). Arms, in order: - * - a node born staged has nothing for an untracked reader (A19 exception 1); - * - an unflushed write serves committed and re-runs the reader in the - * carrying flush (A28); - * - readerSeesCommitted, else the staged value. - */ -export function serve(el: Signal | Computed, c: Computed | null): unknown { +/** Select and interpret the final truth-frame read. An observer explicitly + * served its published frame cannot fall through to unflushed or proposed + * state. Other readers retain the ordinary staged/committed rules; the + * fast signal path keeps its own trivial selection. */ +export function serve( + el: Signal | Computed, + c: Computed | null, + committed: boolean +): unknown { // A node born staged (recompute) has a staged value and no committed one: // an untracked reader has nothing to serve and holds (A19 exception 1) — a // bookkeeping read (`spectate`) likewise, and a children-forbidden reader - // (A32: the frame, which has nothing here). + // (A32: the frame, which has nothing here). An observer served the + // published frame likewise cannot use an unborn node's proposed value. if ( - el._pendingValue !== NOT_PENDING && + (committed || el._pendingValue !== NOT_PENDING) && (el as Computed)._statusFlags & STATUS_UNINITIALIZED && - (!c || spectating || c._config & CONFIG_CHILDREN_FORBIDDEN) + (committed || !c || spectating || c._config & CONFIG_CHILDREN_FORBIDDEN) ) throw new NotReadyError(null); - const u = c && unflushedStaged ? unflushedValue(el) : NOT_PENDING; + const u = !committed && c && unflushedStaged ? unflushedValue(el) : NOT_PENDING; if (u !== NOT_PENDING) { markLateLinker(c!); return u; } - if (readerSeesCommitted(el, c)) return el._value; if ( - c!._config & CONFIG_VERDICT && - !((el as Computed)._statusFlags & STATUS_UNINITIALIZED) && - stagedScreen(c!) + committed || + !c || + c._config & CONFIG_CHILDREN_FORBIDDEN || + (el._pendingValue === NOT_PENDING && + !( + el._config & CONFIG_COMMITTED_ERROR && !((el as Computed)._statusFlags & STATUS_ERROR) + )) || + (c._config & CONFIG_VERDICT && + !((el as Computed)._statusFlags & STATUS_UNINITIALIZED) && + stagedScreen(c)) ) - return el._value; + return readCommitted(el); stagedRead(c!, el); - return el._pendingValue; + return el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; } /** A pass read a staging of this flush: it derives from what the flush may @@ -1970,7 +2013,7 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T !!((el as Computed)._statusFlags & STATUS_UNINITIALIZED) || !el._equals || !el._equals(currentValue, v); - if (!valueChanged) return v; + if (!valueChanged && !(el._config & CONFIG_COMMITTED_ERROR)) return v; // Attribution hook: this committed write is where a re-run chain begins. if (__OBSERVE__ && attrHooks !== null) attrHooks.write(el, currentValue, v); @@ -2044,7 +2087,13 @@ export function setMemo(el: Computed, v: T | ((prev: T) => T)): T { el._flags = (el._flags & ~REACTIVE_CHECK) | REACTIVE_DIRTY; insertIntoHeap(el, dirtyQueue); schedule(); - } else if (el._pendingValue !== NOT_PENDING) el._flags |= REACTIVE_MANUAL_WRITE; + } else if (el._pendingValue !== NOT_PENDING) { + el._flags |= REACTIVE_MANUAL_WRITE; + // The proposal is a success, even when the last derivation failed. + // Keep the published failure until this write's frame commits; a held + // derivation above still re-runs over the write instead of being replaced. + if (el._statusFlags & STATUS_ERROR) clearStatus(el); + } return result; } diff --git a/packages/signals/src/core/effect.ts b/packages/signals/src/core/effect.ts index ac534d855..0c6267077 100644 --- a/packages/signals/src/core/effect.ts +++ b/packages/signals/src/core/effect.ts @@ -2,12 +2,14 @@ import { NOT_PENDING } from "./constants.js"; import { CONFIG_AUTO_DISPOSE, CONFIG_CHILDREN_FORBIDDEN, + CONFIG_HELD, EFFECT_RENDER, EFFECT_TRACKED, EFFECT_USER, REACTIVE_DISPOSED, STATUS_ERROR, - STATUS_PENDING + STATUS_PENDING, + STATUS_UNINITIALIZED } from "./constants.js"; import { computed, @@ -33,7 +35,8 @@ import { resetUnhandledAsync, schedule, setTrackedQueueCallback, - setEffectCallback + setEffectCallback, + txOf } from "./scheduler.js"; import type { Computed, NodeOptions, Owner } from "./types.js"; @@ -75,7 +78,7 @@ export function effect( !node._x?._transaction?._lane && (node._type === EFFECT_USER || options?.schedule ? globalQueue.enqueue(node._type, runEffect.bind(null, node)) - : runEffect(node, node._type)); + : runEffect(node)); if (__DEV__ && !node._parent) { const message = "[NO_OWNER_EFFECT] Effects created outside a reactive context will never be disposed"; @@ -149,7 +152,7 @@ function notifyEffectStatus(this: Effect, status?: number, error?: any): vo } } -function runEffect(node: Effect, type: number): void { +function runEffect(node: Effect): void { if (!node._modified || node._flags & REACTIVE_DISPOSED) return; // A queued run behind a fallback (boundaries.ts) waits for the reveal: a // user effect's, which would read a DOM that is not attached, and a render @@ -160,36 +163,27 @@ function runEffect(node: Effect, type: number): void { // run and goes through. `_modified` stays set; the boundary re-queues the // run (`release`). if (GlobalQueue._heldRun && GlobalQueue._heldRun(node)) return; - // Error arm (#2840), user effects only: a compute-phase error that is still - // the node's settled state at effect time runs the bundle's error handler in - // this same imperative, writable scope. Unwrap the StatusError used for - // source tracking — user code gets the error it threw, as boundaries do. No - // handler: log and keep the system alive (the run was skipped). A handler - // (or logging) consumes the error; a handler throw falls to the shared - // catch below and escalates boundary-or-halt like any effect-phase throw. - // Render effects bypass: their errors route to boundaries synchronously in - // notifyEffectStatus, and a runner queued by an earlier valueChanged in the - // same flush must not be hijacked by a later-arriving error status. - if (node._statusFlags & STATUS_ERROR && node._type === EFFECT_USER) { - const err = unwrapStatusError(node._x?._error); - node._prevValue = node._value; - node._modified = false; - try { - node._errorFn - ? node._errorFn(err, () => { - const prevCleanup = node._cleanup; - node._cleanup = undefined; - prevCleanup?.(); - }) - : console.error(err); - } catch (error) { - if (!globalQueue.notify(node, STATUS_ERROR, STATUS_ERROR)) { - haltReactivity(error); - throw error; - } - } + // A first failure can queue before its creation pass acquires membership. + // Hand an unpublished user's first run to its frame, even if it recovered + // before this run. An initialized node may instead owe a revealed lane's + // callback while its ordinary truth frame remains held. + // Mount-time render work retains its separate DOM-construction rules. + if ( + node._statusFlags & STATUS_UNINITIALIZED && + node._config & CONFIG_HELD && + node._type === EFFECT_USER + ) { + globalQueue.enqueue( + node._type, + (node._boundRunEffect ??= runEffect.bind(null, node)), + txOf(node) + ); return; } + // Both user-effect arms publish a settled outcome in the same scope. + // Render errors route to boundaries during computation; their old queued + // runner must not become a user error callback. + const errorArm = node._statusFlags & STATUS_ERROR && node._type === EFFECT_USER; // Captured before the callback: its own throw errors the node below, but // the compute pass that produced `_value` was clean, so its tail still goes. const cleanPass = node._x?._error == null; @@ -204,18 +198,31 @@ function runEffect(node: Effect, type: number): void { // cascade an observer reports) and what times the callback (the `effect` // record) — facts a production observer needs, not only a dev console. if (__OBSERVE__ && attrHooks !== null) attrHooks.effectRunStart(node); - const prevCleanup = node._cleanup; - node._cleanup = undefined; try { - prevCleanup?.(); - const nextCleanup = node._effectFn(node._value, node._prevValue); - if (__DEV__ && nextCleanup !== undefined && typeof nextCleanup !== "function") { - throw new Error( - `${node._name || "effect"} callback returned an invalid cleanup value. Return a cleanup function or undefined.` - ); + if (errorArm) { + const error = unwrapStatusError(node._x?._error); + // Error handlers choose whether to retire the previous success arm's + // cleanup. Recovery or disposal otherwise retires it as usual. + node._errorFn + ? node._errorFn(error, () => { + const cleanup = node._cleanup; + node._cleanup = undefined; + cleanup?.(); + }) + : console.error(error); + } else { + const prevCleanup = node._cleanup; + node._cleanup = undefined; + prevCleanup?.(); + const nextCleanup = node._effectFn(node._value, node._prevValue); + if (__DEV__ && nextCleanup !== undefined && typeof nextCleanup !== "function") { + throw new Error( + `${node._name || "effect"} callback returned an invalid cleanup value. Return a cleanup function or undefined.` + ); + } + // The final cleanup is invoked by disposeChildren at true disposal. + node._cleanup = nextCleanup as (() => void) | undefined; } - // The final cleanup is invoked by disposeChildren at true disposal. - node._cleanup = nextCleanup as (() => void) | undefined; } catch (error) { ext(node)._error = new StatusError(node, error); node._statusFlags |= STATUS_ERROR; diff --git a/packages/signals/src/core/error.ts b/packages/signals/src/core/error.ts index ceee5dbc7..4698210f6 100644 --- a/packages/signals/src/core/error.ts +++ b/packages/signals/src/core/error.ts @@ -51,9 +51,13 @@ export class StatusError extends Error { public source: any, original: any ) { - super(original instanceof Error ? original.message : String(original), { - cause: original - }); + // Diagnostic formatting cannot replace the outcome being propagated. + // Arbitrary thrown values may have a throwing coercion or message getter. + let message: string | undefined; + try { + message = String(original instanceof Error ? original.message : original); + } catch {} + super(message, { cause: original }); } } diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index c7fd01d0f..589dc8a68 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -52,17 +52,19 @@ import { REACTIVE_PROBE_UNANSWERED, REACTIVE_RECOMPUTING_DEPS, REACTIVE_SCREEN_READ, + STATUS_ERROR, STATUS_PENDING, STATUS_UNINITIALIZED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; -import { ext, stagedRead, tracking } from "./core.js"; +import { ext, readCommitted, stagedRead, tracking } from "./core.js"; import { NotReadyError } from "./error.js"; import { enqueueSub } from "./heap.js"; import { blocked, clock, commitPendingNode, + publishError, flushTransaction, GlobalQueue, holdNode, @@ -107,8 +109,22 @@ export function laneValueOf(el: Signal | Computed): unknown { /** What the screen shows for a lane's node: the lane's value once the lane * has revealed, the committed truth before. */ export function display(el: Signal | Computed): unknown { - const v = el._x!._lane; - return el._x!._transaction!._shown && v !== NOT_PENDING ? v : el._value; + const x = el._x!; + if (x._transaction!._shown) { + if (!(el._config & CONFIG_GUESS) && x._laneError !== undefined) throw x._laneError; + if (x._lane !== NOT_PENDING) return x._lane; + } + return readCommitted(el); +} + +/** A lane's proposed outcome; payload-only laneValueOf remains bookkeeping. */ +export function readLaneValue(el: Signal | Computed): unknown { + if (!(el._config & CONFIG_GUESS)) { + const status = (el as Computed)._statusFlags; + if (status & STATUS_ERROR) throw el._x!._error; + if (status & STATUS_PENDING && el._x!._laneError !== undefined) throw el._x!._laneError; + } + return laneValueOf(el); } /** Guesses written since the last seam (`[node, value, question, …]`). @@ -430,12 +446,15 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal< const effect = (n as any)._type; const guess = n._config & CONFIG_GUESS; const slot = x._lane; + const shownError = x._laneError; // What the screen shows of it (NOT_PENDING: nothing — a lane pass that // errored or pends staged no value), and the lane's latest. const shown = l._shown && !effect ? slot : n._value; const latest = laneValueOf(n); x._lane = NOT_PENDING; + x._laneError = undefined; n._config &= ~(CONFIG_OVERRIDE | CONFIG_GUESS); + if (l._shown && !guess) publishError(n, shownError); if (into === null) { // The parent landed: a guess lands the truth beneath it or reverts to // the one it covered; a derivation's latest commits (its frame with @@ -496,6 +515,7 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal< } if (l._shown) { commitPendingNode(n); + if (!effect) publishError(n, shownError); if (!effect) n._config |= CONFIG_INPUTS_PUBLISHED; } else { if (x._pendingFirstChild !== null || x._pendingDisposal !== null) @@ -536,9 +556,8 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal< * member throws like any. */ export function laneRead(c: Computed | null, el: Signal | Computed): unknown { const guess = el._config & CONFIG_GUESS; - if (c !== null && c._config & CONFIG_AUTHORITATIVE) - return guess && el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; - if (c === null) return laneValueOf(el); + if (c !== null && c._config & CONFIG_AUTHORITATIVE) return NOT_PENDING; + if (c === null) return readLaneValue(el); const l = txOf(el); const status = (el as Computed)._statusFlags; if (!tracking || c._config & CONFIG_CHILDREN_FORBIDDEN) { @@ -551,7 +570,7 @@ export function laneRead(c: Computed | null, el: Signal | Computed | null, el: Signal | Computed)._statusFlags & STATUS_UNINITIALIZED && + !((n as Computed)._statusFlags & STATUS_ERROR) && (n as any)._type !== EFFECT_RENDER ) continue; @@ -787,6 +790,7 @@ export class GlobalQueue implements IQueue { // back: A34 (1).) if ( n._pendingValue !== NOT_PENDING && + !(n._config & CONFIG_COMMITTED_ERROR) && !((n as Computed)._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) && n._equals && n._equals(n._value, n._pendingValue) && @@ -1126,6 +1130,30 @@ export function sameLane(a: Transaction, b: Transaction): boolean { return a === b || (a._links !== null && a._links === b._links); } +/** Publish the terminal outcome with the frame, never with a pending retry. */ +export function publishError(n: Signal | Computed, error: unknown): void { + if (error !== undefined) { + n._x!._committedError = error; + n._config |= CONFIG_COMMITTED_ERROR; + } else if (n._config & CONFIG_COMMITTED_ERROR) { + n._x!._committedError = undefined; + n._config &= ~CONFIG_COMMITTED_ERROR; + } +} + +export function commitStatus(n: Computed): void { + // A manual proposal is a successful answer even while the derivation's + // request remains pending. Publish it just as the successful-payload path + // does; the pending request still owns availability and its eventual answer. + if (!(n._statusFlags & STATUS_PENDING) || n._flags & REACTIVE_MANUAL_WRITE) { + publishError(n, n._statusFlags & STATUS_ERROR ? n._x!._error : undefined); + n._statusFlags &= ~STATUS_UNINITIALIZED; + } + // A committing frame ends the loading window, including a direct first + // failure before the first flush. Keep successful seed history separately. + n._loading = false; +} + export function commitPendingNode(n: Signal): void { const c = n as Partial>; // L2: the commit is where a pass, held or not, stops being uncommitted — @@ -1168,10 +1196,9 @@ export function commitPendingNode(n: Signal): void { ); } } - // The committed value is the first observable answer for a loading-window - // node — the window closes here, not at compute time (#2990). Unconditional - // store to an always-present computed slot. - c._loading = false; + // Publish status after the payload and before dependency disposal can call + // user code. Effect enqueueing above cannot execute its callback inline. + commitStatus(c as Computed); c._flags! &= ~REACTIVE_MANUAL_WRITE; // The dependencies of the pass that produced the value are the frame's now: // the previous frame's tail goes (A30, #3410; `recompute` left it for a @@ -1180,7 +1207,6 @@ export function commitPendingNode(n: Signal): void { // `_error` means the last pass threw, kept its full list, and `_depsTail` // marks where it stopped. if (c._x?._error == null) trimStaleDeps(c as Computed); - if (!(c._statusFlags! & STATUS_PENDING)) c._statusFlags! &= ~STATUS_UNINITIALIZED; // L2: the children this commit publishes are the frame's now — the frame // they replace, parked by the pass (`recompute`), goes. if ( diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 084a423a8..5577c77a4 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -53,7 +53,9 @@ export interface NodeOptions { * Loading boundaries, transitions are never held, and the window is * verdict-quiet (`isPending` stays false — commit #0 answers the question * by declaration; first-load affordances live in the value itself). After - * the first real answer lands, normal refetch/pending semantics apply. + * the first successful or failed answer reveals, normal refetch/pending + * semantics apply. A failure retains this successful payload for memo prev, + * but accessor reads throw the published failure through a pending retry. */ loadingValue?: T; } @@ -77,6 +79,10 @@ export interface NodeExtension { * plain promise flights (no cancellation hook exists). */ _flightTeardown: (() => void) | null; _error: unknown; + /** Error of the last published outcome, retained through pending/recovery. */ + _committedError: unknown; + /** Failure last revealed by an optimistic/derived lane. */ + _laneError: unknown; _blocked: boolean | undefined; _pendingSources: Set> | undefined; _unobserved: (() => void) | undefined; diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 9fc6680ad..ce1df3829 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -11,6 +11,7 @@ import { CONFIG_CHILDREN_FORBIDDEN, + CONFIG_COMMITTED_ERROR, CONFIG_GUESS, CONFIG_HELD, CONFIG_INPUTS_PUBLISHED, @@ -33,6 +34,7 @@ import { context, markLateLinker, pullComputed, + readCommitted, setVerdict, spectating, strictRead, @@ -43,7 +45,7 @@ import { import { warnStrictReadUntracked } from "./dev.js"; import { NotReadyError } from "./error.js"; import { link } from "./graph.js"; -import { display, laneValueOf, verdictLane } from "./lanes.js"; +import { display, readLaneValue, verdictLane } from "./lanes.js"; import { enqueueSub } from "./heap.js"; import { flushTransaction, @@ -199,12 +201,14 @@ function quietPending(el: Computed): boolean { * lands: final now (maintainer, 2026-10-02). */ function heldNotFinal(owner: Computed): boolean { if ((owner._statusFlags & STATUS_PENDING) !== 0 && !quietPending(owner)) return true; - return ( - owner._pendingValue !== NOT_PENDING && - !latestActive && - !owner._x!._reask && - !(owner._statusFlags & STATUS_UNINITIALIZED) && - (!owner._equals || !owner._equals(owner._value as any, owner._pendingValue as any)) + // The reveal exemptions apply to successful and failed outcomes alike. + if (latestActive || owner._x!._reask || owner._statusFlags & STATUS_UNINITIALIZED) return false; + // Internal failure wrappers are non-undefined, even for falsy user causes. + if (owner._statusFlags & STATUS_ERROR) return owner._x!._committedError !== owner._x!._error; + return !!( + owner._config & CONFIG_COMMITTED_ERROR || + (owner._pendingValue !== NOT_PENDING && + (!owner._equals || !owner._equals(owner._value as any, owner._pendingValue as any))) ); } @@ -343,7 +347,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): return el._x!._lane; } if (probing && flying) probeFound = true; - return latestActive && !flying ? laneValueOf(el) : display(el); + return latestActive && !flying ? readLaneValue(el) : display(el); } // Dev strict-read scopes (a component body, an effect callback) warn on a // verdict read as on any untracked read; the pending throw they add for a @@ -355,29 +359,51 @@ function verdictValue(el: Signal | Computed, c: Computed | null): ownerName: (c as any)?._name, nodeName: (owner as any)?._name }); + // An errored derivation has answered: a held/staged value must not mask + // that answer and let a verdict reader commit an old successful result. + // Active optimistic overrides above still supply their own value. const uninitialized = (owner._statusFlags & STATUS_UNINITIALIZED) !== 0; - if (el._config & CONFIG_HELD) { + const held = el._config & CONFIG_HELD; + if (held && !uninitialized) { + const t = txOf(el); + // A render effect re-run (or mounted) outside the verdict lane's flush + // while the lane is blocked — its display held on its own derivation + // in flight — is a stale reader of the lane (#3460, as `laneRead`): the + // committed value now, re-derived at the reveal. Not inside a probe: + // `isPending` is answered below either way. + if ( + tracked && + !probing && + (c as any)._type === EFFECT_RENDER && + passLane === null && + t._verdict !== null && + t._verdict._held && + flushTransaction !== t + ) { + staleReader(c!, t._verdict); + return readCommitted(el); + } + if (tracked) verdictRead(c!, t); + if (probing && heldNotFinal(owner)) probeFound = true; + } + // Interpret failed proposals after selecting a stale render reader's frame, + // but before any successful staging can mask them, including born-held nodes. + if (owner._fn !== undefined && owner._statusFlags & STATUS_ERROR) { + if (!held && tracked && globalQueue._running) + provisionalVerdict(c!, !latestActive && !uninitialized); + throw owner._x!._error; + } + if (held) { if (!uninitialized) { - const t = txOf(el); - // A render effect re-run (or mounted) outside the verdict lane's flush - // while the lane is blocked — its display held on its own derivation - // in flight — is a stale reader of the lane (#3460, as `laneRead`): the - // committed value now, re-derived at the reveal. Not inside a probe: - // `isPending` is answered below either way. + // A projection can recover without a payload staging on its firewall. + // The successful proposed outcome still differs from its published error. if ( - tracked && - !probing && - (c as any)._type === EFFECT_RENDER && - passLane === null && - t._verdict !== null && - t._verdict._held && - flushTransaction !== t - ) { - staleReader(c!, t._verdict); + latestActive && + owner._config & CONFIG_COMMITTED_ERROR && + !(owner._statusFlags & STATUS_PENDING) && + el._pendingValue === NOT_PENDING + ) return el._value; - } - if (tracked) verdictRead(c!, t); - if (probing && heldNotFinal(owner)) probeFound = true; if (latestActive && el._pendingValue !== NOT_PENDING) { // An action body reading another transaction's proposal derives // from it: the two settle as one (A15; posture C, 2026-09-15). An @@ -385,7 +411,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): if (!tracked && flushTransaction !== null && !globalQueue._running) joinFuture(txOf(el)); return heldLatest(el, tracked ? c : null); } - return el._value; + return readCommitted(el); } // Born into the future: its staging is its only value. if (el._pendingValue !== NOT_PENDING && !(owner._statusFlags & STATUS_PENDING)) @@ -408,12 +434,12 @@ function verdictValue(el: Signal | Computed, c: Computed | null): if (globalQueue._running) return el._pendingValue; if (c !== null) markLateLinker(c); } - return el._value; + return readCommitted(el); } if (owner._statusFlags & STATUS_PENDING) { if (!uninitialized) { pendingVerdict(tracked ? c : null, owner); - return el._value; + return readCommitted(el); } // As `read()`: an untracked read of a pending node still re-runs its // reader when the node settles (a projection's pull reads its firewall @@ -421,8 +447,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): if (c !== null && !tracking && !spectating && el !== c) link(el, c); throw owner._x?._error; } - if (owner._fn !== undefined && owner._statusFlags & STATUS_ERROR) throw owner._x!._error; - return el._value; + return readCommitted(el); } // Installed at module evaluation — present exactly when something imports diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 00207ea00..395c5739e 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -771,13 +771,15 @@ function watch( // after: the first pass's own error is the promise's rejection.) computed( () => { - let v: T; + let v!: T; + let error: unknown; + let failed = false; try { v = fn(); } catch (e) { if (e instanceof NotReadyError) throw e; - queueMicrotask(() => onError(unwrapStatusError(e))); - return; + error = e; + failed = true; } const c = getOwner() as Computed; // (Outside a flush a mainline pass joins through `passTx`.) @@ -790,8 +792,11 @@ function watch( staleReader(c, resolveTx(joined)); return; } - inPass?.(v); - queueMicrotask(() => onValue(v)); + if (failed) queueMicrotask(() => onError(unwrapStatusError(error))); + else { + inPass?.(v); + queueMicrotask(() => onValue(v)); + } }, { _extraConfig: config } )._config |= CONFIG_REDERIVE; diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index 387eb1ba7..924198a92 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -231,6 +231,13 @@ export function runProjectionComputed( // `_value`); every later run — a re-derive in a flush, an async landing // — stages and commits with the flush like a memo's recompute. const first = run === 1; + const notifyFamily = (error: unknown, pending: boolean) => { + // Readers subscribe to the leaves, not the firewall. Every failed + // outcome, synchronous or deferred, must wake that same family. + if (first && owner._statusFlags & STATUS_UNINITIALIZED) return; + if (pending) wakeFamily(fam, error); + else errorFamily(fam); + }; const pass = () => storeSetter( draft, @@ -258,17 +265,7 @@ export function runProjectionComputed( // pending on the derive settle. if (!first || !(owner._statusFlags & STATUS_UNINITIALIZED)) settleFamily(fam); }; - const sync = handleAsync(owner, result, commit, (error, pending) => { - // The flight rejected: the leaves' readers learn it from here — - // they do not subscribe to the derive. Pending again (a NotReady - // rejection): the wake, as for the pass. Errored: every live - // reader re-derives and meets the error at its pull (memo parity, - // #2897) — and the flush that runs them advances the clock, so a - // later tracked re-read may retry (core `read`). - if (first && owner._statusFlags & STATUS_UNINITIALIZED) return; - if (pending) wakeFamily(fam, error); - else errorFamily(fam); - }); + const sync = handleAsync(owner, result, commit, notifyFamily); if (!owner._loading) commit(sync as void | T); }, false @@ -279,7 +276,7 @@ export function runProjectionComputed( // A flight went up (NotReady out of the pass — the derive's own, with // the derive as its source): the leaves' readers learn it from here — // they do not subscribe to the derive. - if (e instanceof NotReadyError && !first) wakeFamily(fam, e); + notifyFamily(e, e instanceof NotReadyError); throw e; } // A pass that returned with a flight up (an iterator's sync first yield): diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index f23eaf260..11b6f80c5 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -43,6 +43,7 @@ import { REACTIVE_DIRTY, REACTIVE_RECOMPUTING_DEPS, REACTIVE_STAGED_READ, + CONFIG_COMMITTED_ERROR, STATUS_ERROR, STATUS_PENDING } from "../core/constants.js"; @@ -1755,7 +1756,7 @@ function pullFamily(target: StoreTarget): void { // reader of a memo would — linked, so it re-runs at the landing (the // next settled pass pulls without linking and the stale link trims), a // verdict reader registered, a tracked pass suspended. - if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR)) { + if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR) || fw._config & CONFIG_COMMITTED_ERROR) { // A render effect outside the flight's own flush is the frame, not a // derivation: it keeps what it shows and learns of the landing from the // leaves the landing changes (unchanged leaves say nothing — it is not a diff --git a/packages/signals/tests/async-comparator-release.test.ts b/packages/signals/tests/async-comparator-release.test.ts new file mode 100644 index 000000000..751996eb9 --- /dev/null +++ b/packages/signals/tests/async-comparator-release.test.ts @@ -0,0 +1,71 @@ +import { + createEffect, + createMemo, + createRoot, + createSignal, + flush, + onCleanup +} from "../src/index.js"; + +describe("a failed async comparator releases unobserved lazy readers", () => { + it.each(["fulfillment", "rejection", "comparator"] as const)( + "%s settles the flight's observation lifecycle", + async outcome => { + let resolve!: (value: number) => void; + let reject!: (error: unknown) => void; + const flight = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const marker = new Error("failed outcome"); + let cleaned = 0; + let released = 0; + let setPhase!: (value: number) => void; + let stopObserving!: () => void; + const dispose = createRoot(stop => { + const [phase, writePhase] = createSignal(0); + setPhase = writePhase; + const [input] = createSignal(0, { unobserved: () => released++ }); + const source = createMemo(() => (phase() ? flight : 1), { + equals: (previous, next) => { + if (outcome === "comparator") throw marker; + return previous === next; + } + }); + const lazy = createMemo( + () => { + input(); + onCleanup(() => cleaned++); + return source() + 1; + }, + { lazy: true } + ); + stopObserving = createRoot(disposeObserver => { + createEffect(lazy, () => {}); + return disposeObserver; + }); + return stop; + }); + try { + flush(); + setPhase(1); + flush(); + stopObserving(); + flush(); + // The pending request temporarily stands in for the lost observer. + expect(cleaned).toBe(0); + expect(released).toBe(0); + + outcome === "rejection" ? reject(marker) : resolve(2); + await new Promise(done => setTimeout(done, 0)); + flush(); + // Terminal failure and success both retire that observation. + expect(cleaned).toBe(1); + expect(released).toBe(1); + } finally { + dispose(); + flush(); + } + } + ); +}); diff --git a/packages/signals/tests/committed-error-outcomes.test.ts b/packages/signals/tests/committed-error-outcomes.test.ts new file mode 100644 index 000000000..5664d74ca --- /dev/null +++ b/packages/signals/tests/committed-error-outcomes.test.ts @@ -0,0 +1,502 @@ +import { + createEffect, + createErrorBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest, + NotReadyError, + type SourceAccessor +} from "../src/index.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((ok, bad) => { + resolve = ok; + reject = bad; + }); + return { promise, resolve, reject }; +} +const tick = async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + flush(); +}; + +it.each( + [undefined, null, false, new Error("failed")].flatMap(marker => + [false, true].map(projection => ({ marker, projection })) + ) +)( + "outside reads retain the published outcome across rejection and recovery ($projection, $marker)", + async ({ marker, projection }) => { + let request = deferred(); + let sibling = deferred(); + let source!: () => number; + const [count, setCount] = createSignal(1); + const published: unknown[] = []; + const dispose = createRoot(d => { + if (projection) { + const [store] = createStore( + () => { + count(); + return request.promise.then(answer => ({ answer })); + }, + { answer: 1 } + ); + source = () => store.answer; + } else + source = createMemo(() => { + count(); + return request.promise; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + createEffect(source, { + effect: value => { + published.push({ value }); + }, + error: error => { + published.push({ error }); + } + }); + return d; + }); + try { + flush(); + request.resolve(1); + sibling.resolve(1); + await tick(); + expect(source()).toBe(1); + expect(published).toEqual([{ value: 1 }]); + + request = deferred(); + sibling = deferred(); + setCount(2); + flush(); + request.reject(marker); + await tick(); + expect(published).toEqual([{ value: 1 }]); + expect(source()).toBe(1); + expect(isPending(source)).toBe(true); + expect(isPending(() => latest(source))).toBe(false); + + sibling.resolve(2); + await tick(); + expect(published).toEqual([{ value: 1 }, { error: marker }]); + expect(isPending(source)).toBe(false); + const readError = () => { + try { + source(); + return { returned: true }; + } catch (error) { + return { cause: (error as Error).cause }; + } + }; + expect(readError()).toEqual({ cause: marker }); + + request = deferred(); + sibling = deferred(); + setCount(3); + flush(); + expect(readError()).toEqual({ cause: marker }); + request.resolve(1); + await tick(); + expect(published).toEqual([{ value: 1 }, { error: marker }]); + expect(readError()).toEqual({ cause: marker }); + expect(isPending(source)).toBe(true); + expect(latest(source)).toBe(1); + + sibling.resolve(3); + await tick(); + expect(source()).toBe(1); + expect(published).toEqual([{ value: 1 }, { error: marker }, { value: 1 }]); + } finally { + dispose(); + flush(); + } + } +); + +it.each([false, true])( + "publishes a born-held first rejection with its live birth frame ($0)", + async releaseHoldFirst => { + const request = deferred(); + let sibling = deferred(); + const marker = new Error("initial failure"); + const [count, setCount] = createSignal(1); + let source!: SourceAccessor; + const published: unknown[] = []; + const disposeSlow = createRoot(d => { + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + return d; + }); + flush(); + sibling.resolve(1); + await tick(); + sibling = deferred(); + setCount(2); + flush(); + const dispose = createRoot(d => { + source = createMemo(() => { + count(); + return request.promise; + }); + createEffect(source, { + effect: () => {}, + error: e => { + published.push(e); + } + }); + return d; + }); + try { + flush(); + if (releaseHoldFirst) { + sibling.resolve(2); + await tick(); + // The parent never waits for a slower first load created over it. + expect(count()).toBe(2); + expect(published).toEqual([]); + } + request.reject(marker); + await tick(); + expect(() => latest(source)).toThrow("initial failure"); + if (!releaseHoldFirst) { + expect(published).toEqual([]); + expect(() => source()).toThrow(NotReadyError); + sibling.resolve(2); + await tick(); + } + expect(published).toEqual([marker]); + expect(() => source()).toThrow("initial failure"); + } finally { + dispose(); + disposeSlow(); + flush(); + } + } +); + +it("retains loadingValue as successful prev history without resurfacing it after an error", async () => { + const marker = new Error("seed request failed"); + let request = deferred(); + const previous: (number | undefined)[] = []; + const [count, setCount] = createSignal(0); + let source!: SourceAccessor; + const dispose = createRoot(d => { + source = createMemo( + prev => { + count(); + previous.push(prev); + return request.promise; + }, + { loadingValue: 42 } + ); + createEffect(source, { effect: () => {}, error: () => {} }); + return d; + }); + try { + flush(); + expect(source()).toBe(42); + request.reject(marker); + await tick(); + expect(() => source()).toThrow("seed request failed"); + request = deferred(); + setCount(1); + flush(); + expect(previous).toEqual([42, 42]); + expect(() => source()).toThrow("seed request failed"); + request.resolve(9); + await tick(); + expect(source()).toBe(9); + request = deferred(); + setCount(2); + flush(); + expect(previous).toEqual([42, 42, 9]); + request.resolve(10); + await tick(); + } finally { + dispose(); + flush(); + } +}); + +it("ends the loadingValue window when the first synchronous failure publishes", async () => { + const marker = new Error("first synchronous failure"); + const request = deferred(); + const [retry, setRetry] = createSignal(false); + let source!: SourceAccessor; + const dispose = createRoot(d => { + source = createMemo( + () => { + if (!retry()) throw marker; + return request.promise; + }, + { loadingValue: 42 } + ); + createEffect(source, { effect: () => {}, error: () => {} }); + return d; + }); + try { + expect(() => source()).toThrow("first synchronous failure"); + setRetry(true); + flush(); + expect(isPending(source)).toBe(true); + expect(() => source()).toThrow("first synchronous failure"); + request.resolve(9); + await tick(); + expect(source()).toBe(9); + expect(isPending(source)).toBe(false); + } finally { + dispose(); + flush(); + } +}); + +it.each([ + { fails: false, recoverBeforeFlush: false }, + { fails: true, recoverBeforeFlush: false }, + { fails: true, recoverBeforeFlush: true } +])( + "keeps commit #0 when a born-held memo has a first answer ($fails, $recoverBeforeFlush)", + async ({ fails, recoverBeforeFlush }) => { + const marker = new Error("held first answer"); + let sibling = deferred(); + const [fault, setFault] = createSignal(fails); + const [count, setCount] = createSignal(1); + const disposeSlow = createRoot(d => { + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + return d; + }); + flush(); + sibling.resolve(1); + await tick(); + sibling = deferred(); + setCount(2); + flush(); + let source!: SourceAccessor; + const published: unknown[] = []; + const dispose = createRoot(d => { + source = createMemo( + () => { + count(); + if (fault()) throw marker; + return 7; + }, + { loadingValue: 42 } + ); + createEffect(source, { + effect: value => { + published.push(value); + }, + error: error => { + published.push(error); + } + }); + return d; + }); + try { + if (recoverBeforeFlush) setFault(false); + const finalFailure = fails && !recoverBeforeFlush; + flush(); + expect(source()).toBe(42); + expect(published).toEqual([]); + expect(isPending(source)).toBe(true); + if (finalFailure) expect(() => latest(source)).toThrow("held first answer"); + else expect(latest(source)).toBe(7); + sibling.resolve(2); + await tick(); + expect(published).toEqual([finalFailure ? marker : 7]); + if (finalFailure) expect(source).toThrow("held first answer"); + else expect(source()).toBe(7); + expect(isPending(source)).toBe(false); + } finally { + dispose(); + disposeSlow(); + flush(); + } + } +); + +it("reveals an error fallback with the same frame as a fulfilled answer", async () => { + const marker = new Error("failed"); + let request = deferred(); + let sibling = deferred(); + let source!: SourceAccessor; + const [count, setCount] = createSignal(1); + let displayed: number | string = "mounting"; + let displayedSibling = 0; + const dispose = createRoot(d => { + source = createMemo(() => { + count(); + return request.promise; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + const boundary = createErrorBoundary(source, error => { + expect(error()).toBe(marker); + return "failed"; + }); + createRenderEffect( + boundary, + value => { + displayed = value; + }, + { schedule: true } + ); + createRenderEffect( + slow, + value => { + displayedSibling = value; + }, + { schedule: true } + ); + return d; + }); + try { + flush(); + request.resolve(1); + sibling.resolve(1); + await tick(); + expect([displayed, displayedSibling]).toEqual([1, 1]); + request = deferred(); + sibling = deferred(); + setCount(2); + flush(); + request.reject(marker); + await tick(); + expect([displayed, displayedSibling]).toEqual([1, 1]); + expect(source()).toBe(1); + sibling.resolve(2); + await tick(); + expect([displayed, displayedSibling]).toEqual(["failed", 2]); + expect(() => source()).toThrow("failed"); + } finally { + dispose(); + flush(); + } +}); + +it("retains the first published failure while a different failure is held", async () => { + const first = new Error("first failure"); + const second = new Error("second failure"); + let request = deferred(); + let sibling = deferred(); + const [count, setCount] = createSignal(0); + let source!: SourceAccessor; + const published: unknown[] = []; + const dispose = createRoot(d => { + source = createMemo(() => { + count(); + return request.promise; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + createEffect(source, { + effect: () => {}, + error: error => { + published.push(error); + } + }); + return d; + }); + try { + flush(); + request.reject(first); + sibling.resolve(0); + await tick(); + expect(() => source()).toThrow("first failure"); + request = deferred(); + sibling = deferred(); + setCount(1); + flush(); + request.reject(second); + await tick(); + expect(published).toEqual([first]); + expect(() => source()).toThrow("first failure"); + expect(() => latest(source)).toThrow("second failure"); + expect(isPending(source)).toBe(true); + sibling.resolve(1); + await tick(); + expect(published).toEqual([first, second]); + expect(() => source()).toThrow("second failure"); + expect(isPending(source)).toBe(false); + } finally { + dispose(); + flush(); + } +}); + +it("does not coalesce synchronous same-value recovery with a held outcome transition", async () => { + const marker = new Error("failed"); + let sibling = deferred(); + const [count, setCount] = createSignal(0); + let source!: SourceAccessor; + const published: unknown[] = []; + const dispose = createRoot(d => { + source = createMemo(() => { + if (count() === 1) throw marker; + return 1; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + createEffect(source, { + effect: value => { + published.push(value); + }, + error: error => { + published.push(error); + } + }); + return d; + }); + try { + flush(); + sibling.resolve(0); + await tick(); + sibling = deferred(); + setCount(1); + flush(); + expect(source()).toBe(1); + sibling.resolve(1); + await tick(); + expect(() => source()).toThrow("failed"); + sibling = deferred(); + setCount(2); + flush(); + expect(() => source()).toThrow("failed"); + expect(published).toEqual([1, marker]); + expect(isPending(source)).toBe(true); + sibling.resolve(2); + await tick(); + expect(source()).toBe(1); + expect(published).toEqual([1, marker, 1]); + } finally { + dispose(); + flush(); + } +}); diff --git a/packages/signals/tests/error-held-reveal.test.ts b/packages/signals/tests/error-held-reveal.test.ts new file mode 100644 index 000000000..3beeaa883 --- /dev/null +++ b/packages/signals/tests/error-held-reveal.test.ts @@ -0,0 +1,514 @@ +import { describe, expect, it, vi } from "vitest"; +import { attribution } from "../src/attribution.js"; +import { + createEffect, + createErrorBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + latest, + OBSERVE +} 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 }; +} + +async function settle() { + await new Promise(resolve => setTimeout(resolve, 0)); + flush(); +} + +describe("effect outcomes held by a rendered async sibling", () => { + const cases = ["source", "memo", "async memo", "compute", "async compute", "equals"].flatMap( + kind => ["value", "error"].map(outcome => ({ kind, outcome })) + ); + it.each(cases)("reveals $kind's $outcome arm with the input frame", async ({ kind, outcome }) => { + let request = deferred(); + let siblingRequest = deferred(); + const failure = new Error("failed for input 2"); + const shown: { input?: number; source?: unknown; sibling?: number } = {}; + const [count, setCount] = createSignal(1); + const dispose = createRoot(dispose => { + const source = createMemo( + () => { + count(); + return request.promise; + }, + { + equals: (prev, next) => { + if (kind === "equals" && outcome === "error" && next === 2) throw failure; + return prev === next; + } + } + ); + const observed = + kind === "source" || kind === "equals" + ? source + : createMemo(() => { + const value = source(); + if (kind === "compute" && outcome === "error" && value === 2) throw failure; + if (kind === "async compute" && outcome === "error" && value === 2) + return Promise.reject(failure); + return kind.startsWith("async") ? Promise.resolve(value) : value; + }); + const sibling = createMemo(() => { + count(); + return siblingRequest.promise; + }); + createRenderEffect( + count, + value => { + shown.input = value; + }, + { schedule: true } + ); + createRenderEffect( + sibling, + value => { + shown.sibling = value; + }, + { schedule: true } + ); + createEffect(observed, { + effect: value => { + shown.source = value; + }, + error: error => { + shown.source = error; + } + }); + return dispose; + }); + try { + flush(); + request.resolve(1); + siblingRequest.resolve(1); + await settle(); + expect(shown).toEqual({ input: 1, source: 1, sibling: 1 }); + + request = deferred(); + siblingRequest = deferred(); + setCount(2); + flush(); + if (outcome === "error" && ["source", "memo", "async memo"].includes(kind)) + request.reject(failure); + else request.resolve(2); + await settle(); + expect(shown).toEqual({ input: 1, source: 1, sibling: 1 }); + + siblingRequest.resolve(2); + await settle(); + expect(shown).toEqual({ input: 2, source: outcome === "error" ? failure : 2, sibling: 2 }); + + request = deferred(); + siblingRequest = deferred(); + setCount(3); + flush(); + // Recover to the last good value as well as recovering status. An + // equality-suppressed value notification must not leave an old error. + request.resolve(1); + await settle(); + expect(shown.input).toBe(2); + expect(shown.source).toBe(outcome === "error" ? failure : 2); + siblingRequest.resolve(3); + await settle(); + expect(shown).toEqual({ input: 3, source: 1, sibling: 3 }); + } finally { + dispose(); + flush(); + } + }); +}); + +it.each(["value", "error"])( + "ignores a superseded flight's %s before the replacement flush", + async outcome => { + let request = deferred(); + const [count, setCount] = createSignal(1); + const shown: unknown[] = []; + const dispose = createRoot(dispose => { + const source = createMemo(() => { + count(); + return request.promise; + }); + const boundary = createErrorBoundary(source, error => error()); + createRenderEffect( + boundary, + value => { + shown.push(value); + }, + { schedule: true } + ); + return dispose; + }); + try { + flush(); + request.resolve(1); + await settle(); + request = deferred(); + setCount(2); + flush(); + const superseded = request; + request = deferred(); + // Queue the old answer first, then dirty its inputs. The old flight + // callback runs before the scheduled flush replaces its identity. + if (outcome === "error") superseded.reject(new Error("superseded")); + else superseded.resolve(2); + setCount(3); + await settle(); + expect(shown).toEqual([1]); + request.resolve(3); + await settle(); + expect(shown).toEqual([1, 3]); + } finally { + dispose(); + flush(); + } + } +); + +it("reveals an error without waiting for a disjoint pending frame", async () => { + const requests = [deferred(), deferred()]; + const setters: Array<(value: number) => void> = []; + const shown: Array<{ input?: number; outcome?: unknown }> = [{}, {}]; + const failure = new Error("first frame failed"); + const dispose = createRoot(dispose => { + for (let i = 0; i < 2; i++) { + const [count, setCount] = createSignal(1); + setters.push(setCount); + const source = createMemo(() => { + count(); + return requests[i].promise; + }); + const boundary = createErrorBoundary(source, error => error()); + createRenderEffect( + count, + value => { + shown[i].input = value; + }, + { schedule: true } + ); + createRenderEffect( + boundary, + value => { + shown[i].outcome = value; + }, + { schedule: true } + ); + } + return dispose; + }); + try { + flush(); + requests.forEach(request => request.resolve(1)); + await settle(); + expect(shown).toEqual([ + { input: 1, outcome: 1 }, + { input: 1, outcome: 1 } + ]); + requests[0] = deferred(); + setters[0](2); + flush(); + requests[1] = deferred(); + setters[1](2); + flush(); + requests[0].reject(failure); + await settle(); + expect(shown).toEqual([ + { input: 2, outcome: failure }, + { input: 1, outcome: 1 } + ]); + requests[1].resolve(2); + await settle(); + expect(shown).toEqual([ + { input: 2, outcome: failure }, + { input: 2, outcome: 2 } + ]); + } finally { + dispose(); + flush(); + } +}); + +it("drops an error that recovers before the held frame reveals", async () => { + let request = deferred(); + let siblingRequest = deferred(); + const [count, setCount] = createSignal(1); + const outcomes: unknown[] = []; + const dispose = createRoot(dispose => { + const source = createMemo(() => { + count(); + return request.promise; + }); + const sibling = createMemo(() => { + count(); + return siblingRequest.promise; + }); + createRenderEffect(sibling, () => {}, { schedule: true }); + createEffect(source, { + effect: value => { + outcomes.push(value); + }, + error: error => { + outcomes.push(error); + } + }); + return dispose; + }); + try { + flush(); + request.resolve(1); + siblingRequest.resolve(1); + await settle(); + const oldSibling = siblingRequest; + request = deferred(); + siblingRequest = deferred(); + setCount(2); + flush(); + request.reject(new Error("superseded")); + await settle(); + expect(outcomes).toEqual([1]); + const supersededSibling = siblingRequest; + + request = deferred(); + siblingRequest = deferred(); + setCount(3); + flush(); + request.resolve(1); + await settle(); + expect(outcomes).toEqual([1]); + supersededSibling.resolve(2); + oldSibling.resolve(1); + await settle(); + expect(outcomes).toEqual([1]); + siblingRequest.resolve(3); + await settle(); + expect(outcomes).toEqual([1, 1]); + } finally { + dispose(); + flush(); + } +}); + +it("keeps latest outcomes in their reveal lane instead of holding them with ordinary effects", async () => { + let request = deferred(); + let siblingRequest = deferred(); + const [count, setCount] = createSignal(1); + const failure = new Error("failed"); + const shown: { ordinary?: unknown; latest?: unknown; sibling?: number } = {}; + const dispose = createRoot(dispose => { + const source = createMemo(() => { + count(); + return request.promise; + }); + const verdict = createMemo(() => latest(source)); + const sibling = createMemo(() => { + count(); + return siblingRequest.promise; + }); + for (const [key, accessor] of [ + ["ordinary", source], + ["latest", verdict] + ] as const) { + createEffect(accessor, { + effect: value => { + shown[key] = value; + }, + error: error => { + shown[key] = error; + } + }); + } + createRenderEffect( + sibling, + value => { + shown.sibling = value; + }, + { schedule: true } + ); + return dispose; + }); + try { + flush(); + request.resolve(1); + siblingRequest.resolve(1); + await settle(); + expect(shown).toEqual({ ordinary: 1, latest: 1, sibling: 1 }); + request = deferred(); + siblingRequest = deferred(); + setCount(2); + flush(); + request.reject(failure); + await settle(); + expect(shown).toEqual({ ordinary: 1, latest: failure, sibling: 1 }); + siblingRequest.resolve(2); + await settle(); + expect(shown).toEqual({ ordinary: failure, latest: failure, sibling: 2 }); + } finally { + dispose(); + flush(); + } +}); + +it("holds logging an unhandled compute error like an error callback", async () => { + let request = deferred(); + let siblingRequest = deferred(); + const [count, setCount] = createSignal(1); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const dispose = createRoot(dispose => { + const source = createMemo(() => { + count(); + return request.promise; + }); + const sibling = createMemo(() => { + count(); + return siblingRequest.promise; + }); + createEffect(source, () => {}); + createRenderEffect(sibling, () => {}, { schedule: true }); + return dispose; + }); + try { + flush(); + request.resolve(1); + siblingRequest.resolve(1); + await settle(); + request = deferred(); + siblingRequest = deferred(); + setCount(2); + flush(); + request.reject(undefined); + await settle(); + expect(errorSpy).not.toHaveBeenCalled(); + siblingRequest.resolve(2); + await settle(); + expect(errorSpy).toHaveBeenCalledExactlyOnceWith(undefined); + } finally { + dispose(); + flush(); + errorSpy.mockRestore(); + } +}); + +it.each([false, true])( + "preserves cleanup ownership across error and recovery (manual: %s)", + manual => { + const [count, setCount] = createSignal(0); + const log: string[] = []; + const dispose = createRoot(dispose => { + createEffect( + () => { + const value = count(); + if (value === 1) throw new Error("failed"); + return value; + }, + { + effect: value => { + log.push(`effect ${value}`); + return () => { + log.push(`cleanup ${value}`); + }; + }, + error: (_error, cleanup) => { + log.push("error"); + if (manual) { + cleanup(); + cleanup(); + } + } + } + ); + return dispose; + }); + try { + flush(); + setCount(1); + flush(); + expect(log).toEqual(manual ? ["effect 0", "error", "cleanup 0"] : ["effect 0", "error"]); + setCount(2); + flush(); + expect(log).toEqual(["effect 0", "error", "cleanup 0", "effect 2"]); + } finally { + dispose(); + flush(); + } + expect(log).toEqual(["effect 0", "error", "cleanup 0", "effect 2", "cleanup 2"]); + } +); + +it("treats a fulfilled Error object as a value, not a rejection", async () => { + const request = deferred(); + const value = new Error("ordinary data"); + const successes: unknown[] = []; + const errors: unknown[] = []; + const dispose = createRoot(dispose => { + const source = createMemo(() => request.promise); + createEffect(source, { + effect: value => { + successes.push(value); + }, + error: error => { + errors.push(error); + } + }); + return dispose; + }); + try { + flush(); + request.resolve(value); + await settle(); + expect(successes).toEqual([value]); + expect(errors).toEqual([]); + } finally { + dispose(); + flush(); + } +}); + +it("records the error callback in the same effect scope as the success callback", () => { + const records: string[] = []; + attribution.enable({ log: false, hotTime: false }); + const off = OBSERVE!.records.subscribe("effect", event => { + records.push(event.nodeName); + }); + const [count, setCount] = createSignal(0); + const [message, setMessage] = createSignal(""); + const dispose = createRoot(dispose => { + createEffect( + () => { + if (count()) throw new Error("failed"); + return 0; + }, + { + effect: () => {}, + error: error => { + setMessage((error as Error).message); + } + }, + { name: "outcome" } + ); + return dispose; + }); + try { + flush(); + expect(records).toEqual(["outcome"]); + setCount(1); + flush(); + expect(message()).toBe("failed"); + expect(records).toEqual(["outcome", "outcome"]); + } finally { + dispose(); + off(); + attribution.disable(); + flush(); + } +}); diff --git a/packages/signals/tests/error-wrapper-outcomes.test.ts b/packages/signals/tests/error-wrapper-outcomes.test.ts new file mode 100644 index 000000000..d0bed516a --- /dev/null +++ b/packages/signals/tests/error-wrapper-outcomes.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; +import { createEffect, createMemo, createRoot, createSignal, flush } from "../src/index.js"; + +const coercionFailure = () => { + throw new Error("diagnostic coercion must not replace the outcome"); +}; +const messageGetter = new Error("unused"); +Object.defineProperty(messageGetter, "message", { get: coercionFailure }); +const messageValue = new Error("unused"); +Object.defineProperty(messageValue, "message", { value: { toString: coercionFailure } }); + +it.each([ + { name: "throwing toString", payload: { toString: coercionFailure } }, + { name: "throwing message getter", payload: messageGetter }, + { name: "throwing message coercion", payload: messageValue } +])("preserves the thrown outcome with $name", ({ payload }) => { + const [bad, setBad] = createSignal(false); + const seen: unknown[] = []; + const dispose = createRoot(d => { + const source = createMemo(() => { + if (bad()) throw payload; + return 1; + }); + createEffect(source, { + effect: value => { + seen.push(value); + }, + error: error => { + seen.push(error); + } + }); + return d; + }); + try { + flush(); + expect(seen).toEqual([1]); + setBad(true); + flush(); + expect(seen[1]).toBe(payload); + setBad(false); + flush(); + expect(seen[2]).toBe(1); + } finally { + dispose(); + flush(); + } +}); diff --git a/packages/signals/tests/lane-contract.test.ts b/packages/signals/tests/lane-contract.test.ts index cd30788b8..ec1e25265 100644 --- a/packages/signals/tests/lane-contract.test.ts +++ b/packages/signals/tests/lane-contract.test.ts @@ -303,6 +303,30 @@ describe("lane contract 3 — a lane holds itself", () => { }); describe("lane hardening — errors, disposal, nesting, growth", () => { + it("a first user-effect failure in a shown lane publishes without waiting for its parent", async () => { + const f = fixture(); + const errors: string[] = []; + f.setSrc(1); + f.setOpt(5); + flush(); + const dispose = createRoot(d => { + createEffect( + () => { + if (f.opt() === 5) throw new Error("boom"); + return "ok"; + }, + { effect: () => {}, error: (e: any) => void errors.push(e.message) } + ); + return d; + }); + flush(); + expect(errors).toEqual(["boom"]); + await f.land(5); + expect(errors).toEqual(["boom"]); + dispose(); + f.dispose(); + }); + it("a lane pass that throws errors its node like any pass — contained by a user effect's error arm; the lane and the parent are unaffected", async () => { const f = fixture(); const errors: string[] = []; diff --git a/packages/signals/tests/latest-held-error.test.ts b/packages/signals/tests/latest-held-error.test.ts new file mode 100644 index 000000000..7c122371a --- /dev/null +++ b/packages/signals/tests/latest-held-error.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + createEffect, + 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 }; +} + +async function settle() { + await new Promise(resolve => setTimeout(resolve, 0)); + flush(); +} + +describe("latest of an errored memo held by an async sibling", () => { + it.each([false, true])( + "propagates rejection and recovers (async derived: %s)", + async asyncDerived => { + let request = deferred(); + let siblingRequest = deferred(); + const failure = new Error("failed"); + const shown: { source?: unknown; derived?: unknown; sibling?: number } = {}; + const [count, setCount] = createSignal(1); + let source!: () => number; + let derived!: () => number; + const dispose = createRoot(dispose => { + source = createMemo(() => { + count(); + return request.promise; + }); + const sibling = createMemo(() => { + count(); + return siblingRequest.promise; + }); + derived = createMemo(() => { + const value = latest(source) + 1; + return asyncDerived ? Promise.resolve(value) : value; + }); + createEffect(source, { + effect: value => { + shown.source = value; + }, + error: error => { + shown.source = error; + } + }); + createEffect(derived, { + effect: value => { + shown.derived = value; + }, + error: error => { + shown.derived = error; + } + }); + // A scheduled JSX binding observes the sibling, holding the update. + createRenderEffect( + sibling, + value => { + shown.sibling = value; + }, + { schedule: true } + ); + return dispose; + }); + try { + flush(); + request.resolve(1); + siblingRequest.resolve(1); + await settle(); + expect(shown).toEqual({ source: 1, derived: 2, sibling: 1 }); + + request = deferred(); + siblingRequest = deferred(); + setCount(2); + flush(); + request.reject(failure); + await settle(); + siblingRequest.resolve(2); + await settle(); + + // All requests have settled: the latest derivation must not retain + // its old successful value while the source's settled outcome is error. + expect(shown.source).toBe(failure); + expect(shown.sibling).toBe(2); + expect(() => source()).toThrow("failed"); + expect(() => latest(source)).toThrow("failed"); + expect(isPending(derived)).toBe(false); + expect(shown.derived).toBe(failure); + expect(() => derived()).toThrow("failed"); + + request = deferred(); + siblingRequest = deferred(); + setCount(3); + flush(); + request.resolve(3); + siblingRequest.resolve(3); + await settle(); + expect(shown).toEqual({ source: 3, derived: 4, sibling: 3 }); + expect(derived()).toBe(4); + expect(isPending(derived)).toBe(false); + } finally { + dispose(); + flush(); + } + } + ); +}); diff --git a/packages/signals/tests/loading-value.test.ts b/packages/signals/tests/loading-value.test.ts index c72c728ef..8e1cd4c2f 100644 --- a/packages/signals/tests/loading-value.test.ts +++ b/packages/signals/tests/loading-value.test.ts @@ -265,7 +265,7 @@ describe("createMemo with loadingValue", () => { expect(isPending(user)).toBe(false); }); - it("serves the loading value again on a retry after an error", async () => { + it("retains the published error while a retry after the seed window is pending", async () => { const d1 = deferred(); const d2 = deferred(); let attempt = 0; @@ -293,16 +293,16 @@ describe("createMemo with loadingValue", () => { flush(); expect(errors.length).toBe(1); - // A tracked re-read on a later cycle retries the errored source; the - // retry re-opens serving of commit #0 (the answer still hasn't landed). - let observed: string | undefined; + // The revealed error is a real answer. A tracked re-read retries, but + // the initial seed is now prev history rather than a replacement answer. + let probe!: () => string; createRoot(() => { - const probe = createMemo(() => user()); - observed = probe(); + probe = createMemo(() => user()); + expect(() => probe()).toThrow(NotReadyError); }); flush(); - expect(observed).toBe("placeholder"); - expect(isPending(user)).toBe(false); + expect(() => untrackedRead(user)).toThrow("boom"); + expect(isPending(user)).toBe(true); d2.resolve("recovered"); await tick(); @@ -1007,8 +1007,8 @@ describe("errored loading window: a parked retry keeps the settled error (#2989) } expect(thrown instanceof NotReadyError).toBe(false); expect((thrown as Error).message).toBe("boom"); - // The park stays verdict-quiet. - expect(isPending(user)).toBe(false); + // The published failure stays visible, while a retry is now pending. + expect(isPending(user)).toBe(true); // No wedge: the source settles, the retry runs, the answer lands. dDep.resolve(7); diff --git a/packages/signals/tests/projection-error-outcomes.test.ts b/packages/signals/tests/projection-error-outcomes.test.ts new file mode 100644 index 000000000..cc7932095 --- /dev/null +++ b/packages/signals/tests/projection-error-outcomes.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { createProjection, createSignal, createRoot, createEffect, flush } from "../src/index.js"; +const settle = async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + flush(); +}; +describe("projection outcome notifications", () => { + it.each(["sync", "async"])( + "notifies existing readers of a %s error and same-value recovery", + async kind => { + const marker = new Error("projection failed"); + const seen: unknown[] = []; + const [bad, setBad] = createSignal(false); + let projected!: { value: number }; + const derive = () => { + if (bad()) throw marker; + return { value: 1 }; + }; + const dispose = createRoot(d => { + projected = createProjection(kind === "sync" ? derive : async () => derive(), { value: 0 }); + createEffect(() => projected.value, { + effect: value => { + seen.push(value); + }, + error: error => { + seen.push(error); + } + }); + return d; + }); + try { + flush(); + await settle(); + expect(seen).toEqual([1]); + setBad(true); + flush(); + await settle(); + expect(seen).toEqual([1, marker]); + expect(() => projected.value).toThrow("projection failed"); + setBad(false); + flush(); + await settle(); + expect(projected.value).toBe(1); + expect(seen).toEqual([1, marker, 1]); + } finally { + dispose(); + flush(); + } + } + ); +}); diff --git a/packages/signals/tests/snapshot-error-outcomes.test.ts b/packages/signals/tests/snapshot-error-outcomes.test.ts new file mode 100644 index 000000000..139c2208a --- /dev/null +++ b/packages/signals/tests/snapshot-error-outcomes.test.ts @@ -0,0 +1,145 @@ +import { + clearSnapshots, + createErrorBoundary, + createMemo, + createEffect, + createRenderEffect, + createLoadingBoundary, + createRoot, + createSignal, + flush, + getOwner, + markSnapshotScope, + releaseSnapshotScope, + setSnapshotCapture +} from "../src/index.js"; + +it.each([false, true])( + "does not capture an unpublished placeholder from a born-held memo (failure: %s)", + async fails => { + let resolve!: (value: number) => void; + let request = Promise.resolve(1); + const marker = new Error("born-held failure"); + const [count, setCount] = createSignal(1); + const disposeSlow = createRoot(d => { + const slow = createMemo(() => { + count(); + return request; + }); + createRenderEffect(slow, () => {}, { schedule: true }); + return d; + }); + flush(); + await new Promise(r => setTimeout(r, 0)); + flush(); + request = new Promise(r => { + resolve = r; + }); + setCount(2); + flush(); + let view!: () => number; + const published: unknown[] = []; + setSnapshotCapture(true); + const dispose = createRoot(d => { + markSnapshotScope(getOwner()!); + const source = createMemo(() => { + count(); + if (fails) throw marker; + return 7; + }); + view = createMemo(source); + createEffect(view, { + effect: v => { + published.push(v); + }, + error: e => { + published.push(e); + } + }); + return d; + }); + try { + flush(); + resolve(2); + await new Promise(r => setTimeout(r, 0)); + flush(); + expect(published).toEqual([fails ? marker : 7]); + if (fails) expect(view).toThrow("born-held failure"); + else expect(view()).toBe(7); + } finally { + clearSnapshots(); + dispose(); + disposeSlow(); + flush(); + } + } +); + +it.each([false, true])( + "snapshot reads retain the captured outcome (initial error: %s)", + initiallyBad => { + const marker = new Error("snapshot failure"); + const [bad, setBad] = createSignal(initiallyBad); + let view!: () => number | string; + let owner!: ReturnType; + setSnapshotCapture(true); + const dispose = createRoot(d => { + const source = createMemo(() => { + if (bad()) throw marker; + return 1; + }); + owner = getOwner(); + markSnapshotScope(owner!); + view = createErrorBoundary(source, () => "failed"); + return d; + }); + try { + flush(); + expect(view()).toBe(initiallyBad ? "failed" : 1); + setBad(!initiallyBad); + flush(); + expect(view()).toBe(initiallyBad ? "failed" : 1); + releaseSnapshotScope(owner!); + flush(); + expect(view()).toBe(initiallyBad ? 1 : "failed"); + } finally { + clearSnapshots(); + dispose(); + flush(); + } + } +); + +it("lets a nested Loading fallback resume with a first failure during snapshot capture", async () => { + const marker = new Error("first server answer failed"); + let reject!: (error: unknown) => void; + const request = new Promise((_, bad) => { + reject = bad; + }); + let view!: () => number | string; + setSnapshotCapture(true); + const dispose = createRoot(d => { + markSnapshotScope(getOwner()!); + const source = createMemo(() => request); + view = createErrorBoundary( + () => createLoadingBoundary(source, () => "loading")(), + error => { + expect(error()).toBe(marker); + return "failed"; + } + ); + return d; + }); + try { + flush(); + expect(view()).toBe("loading"); + reject(marker); + await new Promise(resolve => setTimeout(resolve, 0)); + flush(); + expect(view()).toBe("failed"); + } finally { + clearSnapshots(); + 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/packages/signals/tests/writable-error-outcomes.test.ts b/packages/signals/tests/writable-error-outcomes.test.ts new file mode 100644 index 000000000..5157966fc --- /dev/null +++ b/packages/signals/tests/writable-error-outcomes.test.ts @@ -0,0 +1,174 @@ +import { + createEffect, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending +} from "../src/index.js"; + +describe("a direct writable-memo proposal replaces a failed outcome", () => { + it.each([ + [true, 1], + [true, 2], + [false, 1], + [false, 2] + ] as const)("failure at creation: %s, manual payload: %s", (initiallyFailed, payload) => { + let setFail!: (value: boolean) => void; + let source!: () => number; + let write!: (value: number) => void; + let runs = 0; + const previous: (number | undefined)[] = []; + const shown: unknown[] = []; + const marker = new Error("derived failure"); + const dispose = createRoot(stop => { + const [fail, updateFail] = createSignal(initiallyFailed); + setFail = updateFail; + [source, write] = createSignal(prev => { + runs++; + previous.push(prev); + if (fail()) throw marker; + return prev === undefined ? 1 : prev + 1; + }); + createEffect(source, { + effect: value => { + shown.push(value); + }, + error: error => { + shown.push(error); + } + }); + return stop; + }); + try { + flush(); + if (!initiallyFailed) { + setFail(true); + flush(); + } + expect(() => source()).toThrow("derived failure"); + const beforeWrite = runs; + + write(payload); + // A28: the staged successful proposal has not replaced the shown failure. + expect(() => source()).toThrow("derived failure"); + flush(); + expect(source()).toBe(payload); + expect(shown.at(-1)).toBe(payload); + expect(runs).toBe(beforeWrite); + + // A source change re-derives over the successful manual payload as prev. + setFail(false); + flush(); + expect(previous.at(-1)).toBe(payload); + expect(source()).toBe(payload + 1); + } finally { + dispose(); + flush(); + } + }); + + it("a write to a held failed derivation becomes prev for its recovery", async () => { + let finish!: (value: number) => void; + const flight = new Promise(done => (finish = done)); + let source!: () => number; + let write!: (value: number) => void; + let setCount!: (value: number) => void; + const marker = new Error("held failure"); + const shown: unknown[] = []; + const dispose = createRoot(stop => { + const [count, updateCount] = createSignal(0); + setCount = updateCount; + [source, write] = createSignal(prev => { + count(); + if (prev !== 7) throw marker; + return 8; + }); + const slow = createMemo(() => (count() ? flight : 0)); + createRenderEffect(slow, () => {}, { schedule: true }); + createEffect(source, { + effect: value => { + shown.push(value); + }, + error: error => { + shown.push(error); + } + }); + return stop; + }); + try { + flush(); + setCount(1); + flush(); + write(7); + flush(); + expect(() => source()).toThrow("held failure"); + expect(shown.at(-1)).toBe(marker); + finish(1); + await new Promise(done => setTimeout(done, 0)); + flush(); + // The re-derivation's 8 wins; its successful manual prev=7 never reveals. + expect(source()).toBe(8); + expect(shown).toEqual([marker, 8]); + } finally { + dispose(); + flush(); + } + }); + + it.each([false, true])( + "direct proposal during a pending retry, old failure: %s", + async failed => { + let finish!: (value: number) => void; + const flight = new Promise(done => (finish = done)); + let setPhase!: (value: number) => void; + let source!: () => number; + let write!: (value: number) => void; + let runs = 0; + const marker = new Error("retry failure"); + const shown: unknown[] = []; + const dispose = createRoot(stop => { + const [phase, update] = createSignal(0); + setPhase = update; + [source, write] = createSignal(() => { + runs++; + if (phase()) return flight; + if (failed) throw marker; + return 1; + }); + createEffect(source, { + effect: value => { + shown.push(value); + }, + error: error => { + shown.push(error); + } + }); + return stop; + }); + try { + flush(); + setPhase(1); + flush(); + const beforeWrite = runs; + write(2); + flush(); + // Same as a write over a successful pending source: publish the + // manual answer without pretending its outstanding request settled. + expect(source()).toBe(2); + expect(isPending(source)).toBe(true); + expect(runs).toBe(beforeWrite); + finish(3); + await new Promise(done => setTimeout(done, 0)); + flush(); + expect(source()).toBe(3); + expect(isPending(source)).toBe(false); + expect(shown.at(-1)).toBe(3); + } finally { + dispose(); + flush(); + } + } + ); +});