diff --git a/.changeset/add-create-deferred.md b/.changeset/add-create-deferred.md
new file mode 100644
index 0000000000..8e71274ad5
--- /dev/null
+++ b/.changeset/add-create-deferred.md
@@ -0,0 +1,8 @@
+---
+"@solidjs/signals": patch
+"solid-js": patch
+---
+
+Add `createDeferred`: an async memo that may lag the global clock but never leads it. Once it has a committed value, a refetch is served the previous answer instead of suspending — readers keep rendering, the input's write commits immediately, and the landing commits on its own schedule — while `isPending` stays loud (including through sync derivations and derived stores). The first load still suspends to the nearest ``; `loadingValue` composes. A flight asked against a write another node holds reveals with that write (never ahead of it), and one asked by an action's write is the action's. `refresh(d)` and `until` wait for the landed truth, including a predicate that reads the node through a derived memo or a derived store. Errors propagate. On the server it is `createMemo`; on the client it hydrates like one.
+
+Built on the hold model (L2): the clamp is the commit-#0 loading window re-opened per pass, the verdict is an `affects()` mark whose scope is the flight, and "never leads" is the seam's own hold decision over the flush's pending nodes. Core carries two optional hooks (`GlobalQueue._deferredLanded`, `_slotDerive`; the seam sweep rides `affects()`'s release hook), a staged-value gate on `commitPendingNode`'s window close, and `REACTIVE_REASK` kept readable through a pass. The only behaviour change reachable without calling `createDeferred`: a loading-window node committed with nothing staged keeps its window open (before, the commit sweep closed it unconditionally). A held `createDeferred` node re-passed by a mainline write (its landing staged by a downstream async memo's hold) keeps that landing and joins nothing: the write commits mainline and the readers of the held flight catch up at its landing (D11).
diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md
index 88d25ef3d5..9cbb61b632 100644
--- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md
+++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md
@@ -27,6 +27,8 @@ ruling. Anything marked `[ruled]` cites the decision.
**Store** (`store/*`): the target (`target.ts` — `v`, `pb`, `k` the container node, `n` leaves, `h` presence, `dk` deep, `fam`), `readSource` (which backing a read serves — the key is the hold's unit), `pullFamily` (the firewall as a pull), `holdWithDerive`, `stageOn`/`_storePark`/the fold queue (`queueFold`/`drainFolds`/`foldTarget`), `optimistic.ts` (guesses on lanes, `LaneView`, `_laneRebase`), `projection.ts` (`runProjectionComputed`, `wakeFamily` via `propagateStatus`, `settleFamily`, `errorFamily`), `affects.ts` (the store half of marks).
+**Deferred** (`src/deferred.ts`, 2026-10-04 — `docs/create-deferred.md` §6): `createDeferred`'s clamp is the commit-#0 loading window (`_loading`) re-opened per answered pass by a compute wrapper (`deferredCompute`, which registers an async-shaped result through `handleAsync` itself and opens a flight only for one still in the air); its verdict is an `affects()` mark whose scope is the flight (`openFlight`/`closeFlight` — `mark`/`unmark` from `affects.ts`, the push `wake`); "never leads" is the wrapper queueing the node as a pending node of the flush it ran in, for the seam's hold decision. Hooks: `GlobalQueue._deferredLanded` (`asyncWrite`, ahead of the landing's write), `_slotDerive` (store: a slot node's family derive, for `until`'s D9 walk); the seam sweep (a rejected flight, a dead node) rides affects.ts's `_releaseAmbientMarks` (`onSeam`). `commitPendingNode` closes the window only for a staged value; `recompute` keeps `REACTIVE_REASK` readable through the pass, and its T4 arm skips a `CONFIG_DEFERRED` node (D11, 2026-10-04: a held deferred node's re-pass — a downstream hold staged its landing, a mainline write re-passed it — joins nothing and keeps the held landing; the wrapper returns the staging as the pass's result, and the seam sweep re-opens a window a commit closed under a newer question). A node disposed mid-flight outside a flush keeps its mark until the next seam that runs (`disposeChildren` schedules none for it).
+
## 1. Node state fields
A `Signal`/`Computed` participating in async/transitions carries:
diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md
index 397b31e68a..0256b76295 100644
--- a/packages/signals/docs/RULES-INDEX.md
+++ b/packages/signals/docs/RULES-INDEX.md
@@ -22,7 +22,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| vocabulary | rules | cited in src | cited in tests | cited nowhere |
|---|---|---|---|---|
-| A | 34 | 18 | 34 | 0 |
+| A | 34 | 19 | 34 | 0 |
| V | 5 | 1 | 5 | 0 |
| B | 5 | 1 | 5 | 0 |
| C | 4 | 0 | 3 | 1 |
@@ -52,33 +52,33 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:351` | — | derived-write-then-derivation-3733.test.ts×1 errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". |
| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:359` | boundaries.ts×2 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. |
| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… |
-| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… |
+| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | createDeferred.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… |
| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… |
-| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
+| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 deferred.ts×1 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… |
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… |
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … |
| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:213` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… |
-| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … |
+| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 createDeferred.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … |
| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:221` | 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:71` | async.ts×3 constants.ts×1 lanes.ts×7 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… |
+| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 verdict.ts×1 deferred.ts×1 map.ts×1 store.ts×3 | createDeferred.test.ts×1 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:81` | 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:149` | 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:149` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 deferred.ts×2 store.ts×1 | createDeferred.test.ts×2 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:408` | — | 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:415` | — | 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:229` | 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) … |
| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:239` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… |
-| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | affects.ts×2 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×3 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… |
+| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | affects.ts×2 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×3 deferred.ts×3 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 createDeferred.test.ts×2 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… |
| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:309` | 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:111` | — | 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:301` | — | 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:95` | 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… |
+| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:301` | deferred.ts×3 | 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:95` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 deferred.ts×1 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… |
| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:119` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×14 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-branch-verdict-reader-4010.test.ts×1 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:257` | 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:131` | 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:139` | 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… |
-| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | 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:283` | 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… |
+| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 createDeferred.test.ts×1 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:283` | constants.ts×1 core.ts×7 lanes.ts×2 scheduler.ts×2 deferred.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
| id | status | defined | cited in src | cited in tests | statement (at definition) |
@@ -109,17 +109,17 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| id | status | defined | cited in src | cited in tests | statement (at definition) |
|---|---|---|---|---|---|
-| INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:166` | — | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call |
-| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:168` | — | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is |
-| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:172` | invariants.ts×2 scheduler.ts×1 | first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside |
-| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:179` | — | inv4-projection-dispose-shadow.test.ts×3 posture-store-parity.test.ts×1 | - **INV-4 (medium)** After any of the three write paths completes for node `el` |
-| INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:183` | — | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` |
-| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:189` | — | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in |
-| INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:192` | invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node |
-| INV-8 | retired | `docs/INTERNALS-ASYNC-STATE.md:195` | invariants.ts×1 | rules-index.test.ts×1 | - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an |
-| INV-9 | live | `docs/INTERNALS-ASYNC-STATE.md:206` | — | inv4-projection-dispose-shadow.test.ts×1 | - **INV-9 (high)** An `isPending` companion of a DISPOSED owner reads `false` |
-| INV-10 | live | `docs/INTERNALS-ASYNC-STATE.md:211` | — | action-done-window.test.ts×1 | - **INV-10 (high)** Affects-count balance (question-scoped model, 2026-07-13; |
-| INV-11 | live | `docs/INTERNALS-ASYNC-STATE.md:216` | core.ts×1 | spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality |
+| INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:168` | — | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call |
+| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:170` | — | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is |
+| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:174` | invariants.ts×2 scheduler.ts×1 | first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside |
+| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:181` | — | inv4-projection-dispose-shadow.test.ts×3 posture-store-parity.test.ts×1 | - **INV-4 (medium)** After any of the three write paths completes for node `el` |
+| INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:185` | — | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` |
+| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:191` | — | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in |
+| INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:194` | invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node |
+| INV-8 | retired | `docs/INTERNALS-ASYNC-STATE.md:197` | invariants.ts×1 | rules-index.test.ts×1 | - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an |
+| INV-9 | live | `docs/INTERNALS-ASYNC-STATE.md:208` | — | inv4-projection-dispose-shadow.test.ts×1 | - **INV-9 (high)** An `isPending` companion of a DISPOSED owner reads `false` |
+| INV-10 | live | `docs/INTERNALS-ASYNC-STATE.md:213` | — | action-done-window.test.ts×1 | - **INV-10 (high)** Affects-count balance (question-scoped model, 2026-07-13; |
+| INV-11 | live | `docs/INTERNALS-ASYNC-STATE.md:218` | core.ts×1 deferred.ts×1 | createDeferred.test.ts×1 spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality |
## RUL — store rulings
| id | status | defined | cited in src | cited in tests | statement (at definition) |
diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md
index c54d76dcbf..8e57eed437 100644
--- a/packages/signals/docs/create-deferred.md
+++ b/packages/signals/docs/create-deferred.md
@@ -1,41 +1,49 @@
# Design: `createDeferred` — an async memo that may lag, but never leads
-> Status: **proposal** (design agreed 2026-09-08, not started). Origin: a
-> Discord report (2026-09-03) of independent dashboard panels sharing one
-> global selection store, where every panel's commit waited on the slowest
-> panel's fetch (20–200ms panels held hostage by 3–10s panels). Semantics
-> below are settled. The implementation seams were verified against
-> `lanes.ts` / `optimistic.ts` / `verdict.ts` (§6, §8.0): the landing path is
-> the companion write path minus the override, and the one correctness trap
-> found (`refresh` resolving stale) has a fix by precedent (D9). Remaining
-> open items are in §8.1–8.4 and are all small. §9 records why this is a
-> migration primitive, not a niche one:
-> combined with `loadingValue` it is the per-node opt-out from coordination
-> that 1.0 → 2.0 migrations currently lack.
+> Status: **implemented on L2** (design agreed 2026-09-08; implemented
+> 2026-09-28 against the pre-L2 core as #3710; **re-implemented 2026-10-04 on
+> the hold model** — `SPEC-ASYNC-SEMANTICS.md` "The hold model — L2" — as a
+> pay-for-use module, `src/deferred.ts`, §6.4; pinned by
+> `tests/createDeferred.test.ts`, one test per proposition D1–D9 plus §2.1,
+> §8.1, §8.2 and the L2 pins). Origin: a Discord report (2026-09-03) of
+> independent dashboard panels sharing one global selection store, where
+> every panel's commit waited on the slowest panel's fetch (20–200ms panels
+> held hostage by 3–10s panels). Semantics below are settled and unchanged
+> through both implementations; **§6 describes the L2 mechanism** and
+> records, per seam, what the #3710 implementation did on the pre-L2 core
+> and why it moved. The 2026-09-08 sketch's lane-engine landing (§6.2) is
+> two implementations gone: the plain write path landed in #3710, and on L2
+> "never leads" is the seam's hold decision over the flush's pending nodes
+> — the same decision every pending pass gets. §9 records why this is a
+> migration primitive, not a niche one: combined with `loadingValue` it is
+> the per-node opt-out from coordination that 1.0 → 2.0 migrations currently
+> lack.
## 1. The problem
-Solid 2.0 holds while async. A write stages into `_pendingValue` and joins the
-ambient batch; the moment any downstream async memo throws `NotReadyError`,
-`initTransition` adopts the whole batch — the shared write and every
-sibling's staged derivations — into one `Transition`. Every render effect that
-observes a pending async source registers in `_asyncReporters`, and
-`transitionComplete` will not release until all of them settle. One atomic
-commit, gated on the slowest reporter. Meanwhile `stashQueues` parks the
-entire effect-queue tree, so even effects whose sources never went pending
-wait.
+Solid 2.0 holds while async. A write stages into `_pendingValue` for the
+flush; the moment a render effect observes a downstream async memo pending,
+the flush _parks_ (L2, `SPEC-ASYNC-SEMANTICS.md` "The hold model"): every
+node staged in it — the shared write and every sibling's staged derivation —
+is held by one `Transaction`, and the transaction lands only when no frame on
+screen derives from a flight it holds (`blocked`). One atomic commit, gated
+on the slowest observed flight. Meanwhile the flush's effect runs are
+stashed with the transaction, so even effects whose sources never went
+pending wait. (Pre-L2 the same hold was `initTransition` adopting the batch,
+`_asyncReporters`, `transitionComplete` and `stashQueues`; the shape of the
+problem did not change.)
This is the right default: committing the fast panel early would show
new-period data beside old-period data. But it means there is no way to say
"this subtree is an independent visual unit; let it fall behind rather than
hold everyone." Today's escape hatches each miss:
-| Hatch | What it does | Why it doesn't answer the report |
-| --- | --- | --- |
-| `latest(() => x())` | Per-read: never suspends, serves staged/committed | Per read-site — impractical at scale; one non-`latest` read of the chain re-entangles everything; **leads** the clock (§4.2) |
-| ` x())}>` | Unmounts the panel while pending; `transitionComplete` prunes its dead reporters | Works, but is fallback-shaped (loses DOM/state) and incidental — the user rediscovered "show a fallback on refetch" through a side door |
-| `` | Resets the boundary on key change; collects pending locally instead of joining the hold | The designed fallback affordance — right answer when a skeleton is acceptable, no answer when old content must stay visible |
-| `createEffect(x, v => setMirror(v))` | User effects don't report pending (`notifyEffectStatus` only notifies for `EFFECT_RENDER`), so the transition completes without the mirror's consumers | Amputates the node: `isPending(mirror)` is always false, `refresh` can't reach the query, errors route to the effect's owner, laziness is lost (always-on subscriber), lands one flush late, first load must be hand-rolled |
+| Hatch | What it does | Why it doesn't answer the report |
+| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `latest(() => x())` | Per-read: never suspends, serves staged/committed | Per read-site — impractical at scale; one non-`latest` read of the chain re-entangles everything; **leads** the clock (§4.2) |
+| ` x())}>` | Unmounts the panel while pending; `transitionComplete` prunes its dead reporters | Works, but is fallback-shaped (loses DOM/state) and incidental — the user rediscovered "show a fallback on refetch" through a side door |
+| `` | Resets the boundary on key change; collects pending locally instead of joining the hold | The designed fallback affordance — right answer when a skeleton is acceptable, no answer when old content must stay visible |
+| `createEffect(x, v => setMirror(v))` | User effects don't report pending (`notifyEffectStatus` only notifies for `EFFECT_RENDER`), so the transition completes without the mirror's consumers | Amputates the node: `isPending(mirror)` is always false, `refresh` can't reach the query, errors route to the effect's owner, laziness is lost (always-on subscriber), lands one flush late, first load must be hand-rolled |
The missing primitive is the effect-write-back's decoupling with the graph
edge kept intact. The only edge property that causes the hold is _outward
@@ -144,13 +152,13 @@ which is why it needs no component.
The differences are in the panel's timing relative to everything else:
-| | Hold (default async memo) | `createDeferred` |
-| --- | --- | --- |
-| When the data swaps | At the group's commit (slowest reporter lands) | At _this_ fetch's landing |
-| How long `isPending` is true | Until the group commits — the fast panel shows "refreshing" for the slow panel's 10s | Until _this_ fetch lands — 200ms |
-| Selector label vs. panel data | Flip together | Label flips at once (nobody holds the write); data follows when it lands. The interval is the tear (§4.3) — `isPending` being loud is what makes it legible |
-| Held sync writes (theme) | Flip at commit | Flip at commit — same as hold; `latest` is the one that would flip early |
-| In-flight future | Peekable via `latest` | Not visible through this node; `latest(() => d())` is a no-op. Overrides on _other_ nodes still show (A17) |
+| | Hold (default async memo) | `createDeferred` |
+| ----------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| When the data swaps | At the group's commit (slowest reporter lands) | At _this_ fetch's landing |
+| How long `isPending` is true | Until the group commits — the fast panel shows "refreshing" for the slow panel's 10s | Until _this_ fetch lands — 200ms |
+| Selector label vs. panel data | Flip together | Label flips at once (nobody holds the write); data follows when it lands. The interval is the tear (§4.3) — `isPending` being loud is what makes it legible |
+| Held sync writes (theme) | Flip at commit | Flip at commit — same as hold; `latest` is the one that would flip early |
+| In-flight future | Peekable via `latest` | Not visible through this node; `latest(() => d())` is a no-op. Overrides on _other_ nodes still show (A17) |
The removed option is exactly one: peeking at the in-flight future of this
node. Everything else the user could do before, they can do after.
@@ -162,12 +170,12 @@ a decision. Teach a UX question the reader can already answer:
> **When this data is being refetched, what should the user see?**
-| The user should see… | Use |
-| --- | --- |
-| Nothing change until everything related is ready | default `createMemo(async …)` |
-| …but the control I just touched reflects my input _now_ | `latest()` on the control, `isPending` to dim the held content |
-| A placeholder | `` (first load); `` (refetch when the subject changed) |
-| The previous answer with a "refreshing" indicator | `createDeferred` + `isPending` |
+| The user should see… | Use |
+| ------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| Nothing change until everything related is ready | default `createMemo(async …)` |
+| …but the control I just touched reflects my input _now_ | `latest()` on the control, `isPending` to dim the held content |
+| A placeholder | `` (first load); `` (refetch when the subject changed) |
+| The previous answer with a "refreshing" indicator | `createDeferred` + `isPending` |
Rows one and two are the same view: the standard held interaction is _tab
highlights immediately, content waits, content dims while it waits_ — `latest`
@@ -283,13 +291,13 @@ lands, landings commit ambiently.
So `latest` and `createDeferred` share "never suspend, never report" and
differ on two axes:
-| | `latest(fn)` | `createDeferred(fn)` |
-| --- | --- | --- |
-| Grain | Read-site lens; leaves no node | Graph node with identity |
-| Staged (held) values | Served — **leads** | Never served — **lags only** |
-| Own async in flight | Serves committed stale | Serves committed stale |
-| Machinery | `optimisticComputed` shadow per read source | One node; no per-read companions |
-| Analog | `createOptimistic` for reads (unconfirmed frontier) | Confirmed-only counterpart |
+| | `latest(fn)` | `createDeferred(fn)` |
+| -------------------- | --------------------------------------------------- | -------------------------------- |
+| Grain | Read-site lens; leaves no node | Graph node with identity |
+| Staged (held) values | Served — **leads** | Never served — **lags only** |
+| Own async in flight | Serves committed stale | Serves committed stale |
+| Machinery | `optimisticComputed` shadow per read source | One node; no per-read companions |
+| Analog | `createOptimistic` for reads (unconfirmed frontier) | Confirmed-only counterpart |
Committed-only is also cheaper: the companion-per-source machinery exists to
expose in-flight staged values, which this never does.
@@ -402,125 +410,233 @@ heavy chart lags) is real but narrow. Not forbidden, not advertised.
## 5. Interaction table
-| With | Behavior | Note |
-| --- | --- | --- |
-| `` | Owns first load (node transparent while uninitialized). Never sees refetch pending from a deferred node. | Nesting order irrelevant. Inside a deferred consumer, Loading collapses to a pure first-paint concern and `isPending` is _the_ refetch affordance. |
-| `` | Reset re-shows fallback only when refetch pending _arrives_ — a deferred node's refetch never arrives. | Inert for deferred sources; documented, not warned (the boundary cannot cheaply know it covers one). A "hard reset re-opens the uninitialized path" upgrade remains available (§8.0). |
-| `` | Rejections propagate normally. | Deferred defers pending, not errors. |
-| `` | Untouched — coordinates first reveals only. | |
-| `createOptimistic` / `action` | Overrides visible to all ordinary readers (A17), so optimistic UI inside a deferred consumer works. A deferred panel does not hold an action's transition open. | The latter is the point. |
-| `isPending` | Verdict-loud (§2.4): the latest-form verdict (A8), made the only form. | Pending does not propagate through sync derivations of a deferred node (they never re-run), so the probe needs a reachability walk for derived reads including store leaves (§8.1). In the initial implementation. |
-| `latest` | Complementary, not competing: `latest` is the input-side affordance (show my change while its consequences load), `createDeferred` the data-side one (show the previous result while the next loads). `latest(() => deferred())` is legal and a no-op (nothing staged to lead with). | In a held view they compose: `latest` on the control, `isPending` dimming the content. |
-| `until` / `refresh` | Edges intact, so both reach the node. Authoritative readers bypass the clamp (D9): `refresh(deferred)` parks and resolves on the landing; `until(() => deferred())` sees only settled truth. | Without the bypass `refresh` resolves with the stale value — a real bug (§6.3a). |
-
-## 6. Implementation sketch
-
-Seams, in order of confidence.
-
-### 6.1 Read contract = a loading window that never closes
-
-The loading window already implements the read side exactly:
+| With | Behavior | Note |
+| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `` | Owns first load (node transparent while uninitialized). Never sees refetch pending from a deferred node. | Nesting order irrelevant. Inside a deferred consumer, Loading collapses to a pure first-paint concern and `isPending` is _the_ refetch affordance. |
+| `` | Reset re-shows fallback only when refetch pending _arrives_ — a deferred node's refetch never arrives. | Inert for deferred sources; documented, not warned (the boundary cannot cheaply know it covers one). A "hard reset re-opens the uninitialized path" upgrade remains available (§8.0). |
+| `` | Rejections propagate normally. | Deferred defers pending, not errors. |
+| `` | Untouched — coordinates first reveals only. | |
+| `createOptimistic` / `action` | Overrides visible to all ordinary readers (A17), so optimistic UI inside a deferred consumer works. A deferred panel does not hold an action's transition open. | The latter is the point. |
+| `isPending` | Verdict-loud (§2.4): the latest-form verdict (A8), made the only form. | Pending does not propagate through sync derivations of a deferred node (they never re-run), so the probe needs a reachability walk for derived reads including store leaves (§8.1). In the initial implementation. |
+| `latest` | Complementary, not competing: `latest` is the input-side affordance (show my change while its consequences load), `createDeferred` the data-side one (show the previous result while the next loads). `latest(() => deferred())` is legal and a no-op (nothing staged to lead with). | In a held view they compose: `latest` on the control, `isPending` dimming the content. |
+| `until` / `refresh` | Edges intact, so both reach the node. Authoritative readers bypass the clamp (D9): `refresh(deferred)` parks and resolves on the landing; `until(() => deferred())` sees only settled truth. | Without the bypass `refresh` resolves with the stale value — a real bug (§6.3a). |
+
+## 6. Implementation (as landed on L2, 2026-10-04)
+
+Seams, in order of confidence. Each records the L2 mechanism, then — where
+it differs — what #3710 did on the pre-L2 core and why the move. The
+vocabulary is the spec's L2 module map: `core/scheduler.ts` (`Transaction`,
+`holdNode`, `blocked`, the seam `settle`, `land`), `core/core.ts` (`read`'s
+hold rules, `recompute`), `core/verdict.ts` (one cold read path), `affects.ts`
+(marks).
+
+**In one paragraph.** A deferred node is a plain memo whose answered passes
+run inside the commit-#0 loading window (A27): the pass neither throws nor
+goes `STATUS_PENDING`, so nothing observes a flight, the flush does not park,
+and the write that re-asked the node commits with its tick (D1). The window
+is the flight. Its verdict is a mark on the node — the `affects()` channel
+(A24), whose scope is the flight: registered at its start, released at its
+landing (D4). "Never leads" (D2) is the seam's own hold decision: the
+wrapper queues the node as a pending node of the flush it ran in, with
+nothing staged; if that flush parks, the node is held with the transaction
+like any pending node, and its landing re-enters the hold (A34 (1)). The
+authoritative readers ask the module (D9). Nothing lane-shaped, nothing in
+`read()`.
+
+### 6.1 Read contract = the loading window, re-opened per pass
+
+The loading window already implements the read side exactly — unchanged on
+L2 (A27 Mechanism (L2): "unchanged async core — `_loading` on the node;
+`handleAsync` serves `_value` instead of throwing while set;
+`parkLoadingWindow` registers the flight's dependents without read-visible
+pending"):
```ts
// core/async.ts — handleAsync
-if (el._loading) return el._value; // serve committed, no transition
-globalQueue.initTransition(resolveTransition(el));
+if (el._loading) return el._value; // serve committed
throw new NotReadyError(context!);
// core/core.ts — recompute catch
-if (notReady && el._loading) parkLoadingWindow(el, e); // register for settle,
-// "NO read-visible pending status, no downstream propagation, no transition,
-// no lane registration — the committed loading value keeps serving."
+if (notReady && el._loading) parkLoadingWindow(el, e); // register for settle,
+// "NO read-visible pending status and no downstream propagation — the
+// committed loading value keeps serving."
```
-Two differences from `loadingValue`: the window closes at the first real
-answer (`el._loading = false` at every landing site) — deferred keeps it open
-for every recompute after commit #0; and the window is verdict-quiet by
-design — deferred must be verdict-loud. Concretely: a `CONFIG_DEFERRED` bit;
-the landing sites that clear `_loading` leave it set when the bit is on (or
-the checks become `_loading || _config & CONFIG_DEFERRED`); the uninitialized
-first flight is _not_ windowed (the node starts `STATUS_UNINITIALIZED` like a
-plain memo so §2.6 holds). The verdict side (§6.3) supplies the loudness.
-
-Hot-path cost: `read()` must not learn a new branch. Keep the node's _public_
-status clean — never `STATUS_PENDING` from a consumer's perspective — and carry
-in-flight state where the verdict machinery already looks.
-
-### 6.2 Landing schedule = the lane engine (verified against `lanes.ts` / `optimistic.ts`)
-
-A landing that arrives while an unrelated transition is active would be
-adopted into it (`currentBatch = this._batch = activeTransition`) and held —
-the panel becomes hostage to the theme's image loads. The lane engine is the
-existing escape, and reading it shows every piece is already there:
-
-- **A lane is held only by observed async.** `laneHeld` returns true iff one
- of the lane's `_pendingAsync` nodes is in the transition's
- `_asyncReporters`. A deferred node never throws downstream, so it is never
- a reporter, so its lane is **never held**, so `runLaneEffects` flushes its
- effects on every flush — including under an incomplete unrelated
- transition.
-- **Lanes merge with lanes, not transitions.** `assignOrMergeLane` merges at
- convergence points between two _lanes_. A transition-held plain write
- (theme) has no lane. There is no path by which a deferred lane is "pulled
- into" the theme transition.
-- **Reading held sources under a lane already lags-never-leads.** A reader
- recomputing under a lane that touches a mid-transition held source gets
- the _committed_ value and is recorded in `_gatedSubs` for replay at that
- transition's commit (`laneReadsCommitted`, `gatedRead`). New data + old
- theme now; repaint at theme commit. This is D2/D3 for lane readers, already
- implemented for optimism.
-- **Downstream derivations commit, not stage.** Sync memos recomputing under
- lane posture direct-commit `_value` (the #3009 comment in `recomputeLane`),
- so the landing propagates as committed truth through derivations.
-- **The #3009 wake-only demotion does not fire** (it keys on
- `_parentSource`, which a deferred node lacks).
-
-**The landing path already exists verbatim.** `asyncWrite` has a lane branch
-(`else if (lane) { … el._value = value; el._time = clock;
-GlobalQueue._syncCompanions?.(el, value); insertSubs(el, true); }`) that
-direct-commits, pokes companions, and propagates on the optimistic-dirty
-channel — shipped today for `latest` shadows. A deferred node has no override
-slot, so it skips the optimistic hold branch above it and reaches this one
-_iff_ `resolveLane(el)` is truthy at landing. The entire landing-side change
-is therefore: **ensure the lane at landing time**, just before that branch —
-`resolveLane(el) ?? getOrCreateLane(el)` (no override, no `_optimisticNodes`
-entry) via a `GlobalQueue._deferredLane?.(el)` hook. At landing, not flight
-start: lanes are recycled at their owning transition's completion (or at the
-ambient flush for orphans, `cleanupCompletedLanes(null)`), so a lane made at
-flight start is gone by the time a 10s fetch lands. Recycling always runs the
-lane's leftover effects first, so nothing is lost.
+So the clamp is not a core branch at all: it is a **compute wrapper**
+(`deferredCompute`, `src/deferred.ts`). Once the node has answered, every
+pass re-opens the window (`el._loading = true`) before calling the user's
+compute. A thrown `NotReadyError` is a flight (the wrapping form, D8: core
+parks the node on the upstream, window open); a real error closes the window
+and is the answer (D6); a sync value closes it. An async-shaped result — a
+thenable or an async iterable, checked untracked — is **registered by the
+wrapper itself** through `handleAsync`, as a projection's body registers its
+own flight (`recompute` takes the self-registered value): when `handleAsync`
+returns, a thenable that resolved synchronously or a stream whose first
+yield was buffered has _landed_ — the window is closed and no flight opens;
+only a result still in the air opens one. The wrapper classifies the
+_outcome_, not the shape, so the verdict never flickers for an answer that
+was never in flight (A10). (#3710 classified the shape before `handleAsync`
+ran and opened a flight for every thenable; a sync-resolved one then closed
+its window at a site with no hook — the §8.3 "known edge", gone.)
+
+"Answered" is sticky in the wrapper's closure: initialized and the window
+closed. The uninitialized first flight is _not_ windowed — the node starts
+`STATUS_UNINITIALIZED` like a plain memo, so D5 holds and `` owns
+the first load; on L2 an uninitialized source is nobody's frame at the seam
+(a loading source is not held), its observers are, and its first landing is
+commit #0. With `loadingValue` (§2.1) the node is born committed with its
+window open: the first flight is the A27 window (verdict-quiet), and the node
+answers when that flight lands; from there refetches are loud.
+
+`_loading` keeps its meaning — the committed value is serving while an
+answer is in flight — and every site that makes an answer observable
+closes it: `asyncWrite`'s equal-value landing (nothing staged),
+`commitPendingNode` for a staged landing (this flush's sweep, or a
+transaction's `land`), the sync paths. The one core change on this side:
+`commitPendingNode`'s window close is **gated on a staged value** (it was
+unconditional), so a deferred node committed with nothing staged — queued
+for the seam's hold decision (§6.2) and swept by a committing flush, or held
+by a transaction that landed before the flight did — keeps its window open:
+the flight is still up. Byte-neutral (the line moved into the existing
+block).
+
+The node's _public_ status stays clean — never `STATUS_PENDING` from a
+consumer's perspective, never a blocker of any transaction (`blocked` counts
+pending held nodes a frame observes; a deferred node is never pending) —
+and the in-flight state is carried where the verdict looks (§6.3). `read()`
+is untouched (§6.3a).
+
+### 6.2 Landing schedule = the plain write path; never leads = the seam's hold
+
+**The landing** is `asyncWrite`'s plain path — `setSignal(el, () => value)`
+then `flush()` — what it always was for a memo whose flight nobody observed.
+On L2 the question the 2026-09-08 sketch answered with lanes ("a landing
+during an unrelated held transition is adopted into it and held hostage")
+does not arise: a transaction holds **nodes**, not flushes (ruling 1, "one
+frame concept"). The theme's transaction holds the theme's staged nodes and
+has stashed its own flush's runs; the panel's landing runs its own flush,
+joins nothing (the deferred node is not held), commits at its seam, and its
+consumers' effects run. Pinned by "the deferred consumer repaints while the
+theme transition is still held" (D3).
+
+**Never leads (D2).** The plain path does not know the flight was asked
+against a staged write, and on L2 the deferred pass is the one pass the
+seam would otherwise never see: a pass that _changes_ is queued (staged) and
+held if the flush parks; a pass that goes _pending_ is queued and held; a
+deferred pass does neither — it served the committed value — so the seam
+would not list it, and its landing would commit beside a `period` the
+sibling still holds. So the wrapper queues it: a flight opened inside a
+flush is queued as a pending node of that flush (`openFlight` →
+`queuePendingNode(el)`) with nothing staged, and the seam decides as it does
+for every pending node — a flush that parks holds it (`holdNode`,
+`CONFIG_HELD`, `_x._transaction`), a flush that commits sweeps it
+(`commitPendingNode` with nothing staged: a no-op, the window gated open,
+§6.1). Held, its landing's `setSignal` re-enters the hold (A34 (1): a write
+to a held node joins its transaction): the answer stages under the
+transaction and reveals with the write it was asked with, never ahead of
+it. Not pending, it never blocks that transaction (`blocked`), so the
+sibling's landing lands the transaction with the deferred node committed
+unstaged — the write reveals, the panel follows. Pinned by both orders.
+
+The same decision, from the pass's side: a flight asked against this
+flush's staging is this frame's (ruling 3, "a staging read is the frame's")
+— the deferred pass read the staging as a derivation would, and its result
+is a question whose answer belongs to the frame that asked it. Queueing is
+how `recompute` hands a pass to the seam for exactly that decision; the
+wrapper does it for a pass `recompute` would not have queued.
+
+**Inside an action** the same mechanism makes the flight the action's work:
+the action's write is held by its transaction, the deferred pass runs in
+the flush that parks into it, the node is held with it, and the landing
+stages under it until the body returns — or, the body returning first, the
+transaction lands with the node unstaged and the panel follows ambiently.
+The action never waits on the flight. Pinned ("L2 — inside an action").
+
+(#3710 did the same queueing against the pre-L2 batch: "the adoption stamps
+the deferred node with the transition like every other pending node; its
+landing then arrives at `setSignal` with `el._transition` set and takes the
+existing 'write to a held node re-enters its transition' arm." The L2
+form is the seam's `holdNode` and `setSignal`'s `joinFuture(txOf(el))`.)
+
+**Downstream** is untouched: the landing is an ordinary commit, so sync
+derivations recompute on the ordinary rails; D10 stays withdrawn.
The wrapping form (§4.7, upstream `NotReadyError`) takes the
-`parkLoadingWindow` branch in `recompute`'s catch with `_loading ||
-CONFIG_DEFERRED`; when the upstream settles, the settle walk recomputes the
-node and its sync result commits under the same lane posture.
-
-**Convergence with an in-flight optimistic action.** An effect that reads a
-deferred node **and** an optimistic override whose action is in flight
-converges → `mergeLanes` → the merged lane is held by the action's observed
-async → that effect's repaint waits for the action and lands in the same
-paint as the override drop (A18). Scoped to exactly the converged consumers;
-correct, not merely accepted — see §8.2. Two deferred lanes merging is
-harmless since neither can be held.
-
-**Spec consequence.** A18's "`_value` changes at commit points, period"
-already has lane posture as an unstated exception; deferred makes it
-load-bearing. Amend to "…or at a lane landing."
-
-### 6.3 Verdict = the `affects()` mark mechanics, verdict channel only
+`parkLoadingWindow` branch in `recompute`'s catch (the wrapper opened the
+window); when the upstream settles, the settle walk re-runs the node
+(`_blocked`) and its sync result stages on the same plain path — under the
+upstream's transaction if the upstream was held (the pass is served the
+staged value and joins, A29), so a wrapped held memo's value reveals with
+the hold while the deferred node's own consumers were never suspended.
+Pinned by D8.
+
+**Convergence with an in-flight optimistic action** (§8.2) is not a
+lane-merge question. The landing is an ambient commit — the same event as a
+signal written mid-action — and A17 answers it: a consumer of both the
+deferred node and the guess keeps seeing the guess, and repaints with the
+new data now. Pinned: `["rows-1:false", "rows-1:true", "rows-2:true"]` then
+the revert at settle.
+
+### 6.3 Verdict = an `affects()` mark whose scope is the flight
The loading window is verdict-quiet _because it has nothing to see_: the node
-carries no `STATUS_PENDING` and no held `_pendingValue`, and
-`computePendingState` reads exactly those. A deferred node in flight is in
-the same state — and, because its committed value doesn't change, its
-derivations never re-run and never pick up status either. So the verdict
-cannot ride the recompute rails; it has to ride the mark channel `affects()`
-already built (pull-derived coverage via `markWalk` reachability, push via
-`_repollVerdicts`). Full mechanism and placement in §8.1. In one line: the
-"mark" is `CONFIG_DEFERRED && _x._inFlight && !quiet` on the node, `markWalk`
-looks for it, flight start and landing repoll. `notifyMarkBoundaries` and
-transaction registration are skipped — that is what "doesn't hold above"
-means mechanically.
+carries no `STATUS_PENDING` and no staged `_pendingValue`, and L2's one
+verdict read path (`verdictValue`, core/verdict.ts) answers exactly those
+(`pendingVerdict`, `heldNotFinal`). A deferred node in flight is in the same
+state — and, because its committed value doesn't change, its derivations
+never re-run and never pick up status either. So the verdict cannot ride the
+recompute rails; it rides the mark channel `affects()` already built on L2
+(ruling 9): **a count on the node** (`_x._marks`, affects.ts `mark`),
+pull-derived coverage at probe time (`GlobalQueue._marked` → `marked`: the
+probed node, or one reachable through its current dependencies; a read
+through a derived store's family pulls the derive inside the verdict window,
+`pullFamily` → `readNode(fw)`, so the leaves are covered too), and a push —
+the verdict readers downstream re-derive — at registration and release.
+
+What differs from `affects()` is the mark's **scope**. An `affects()` mark is
+listed with its transaction (released at the landing) or the ambient list
+(released at the seam, kept while the node's flight is up). A deferred
+flight's mark is scoped to the flight: registered by `openFlight` (`mark` +
+the push), released by `closeFlight` (`unmark` + the push) — from the
+landing hook, ahead of the landing's write (§6.4), from the wrapper on a sync
+answer, and from the seam for a rejected flight or a dead node. The module
+owns the lifecycle; affects.ts exports the two counters (`mark`/`unmark`)
+and nothing else of its machinery is touched. Full lifecycle in §8.1.
+
+**Quiet re-asks** (A24) are open flights without a mark: the waiters park
+on them (D9), the verdict stays `false`. A new question superseding a quiet
+flight marks it; a re-ask superseding a loud flight leaves the mark (A19
+exc. 2: the re-ask does not launder the new question).
+
+**The push** (`wake`, deferred.ts) walks the node's subscribers through
+derivations, stopping at effects — affects.ts's `repoll`, with one more
+clause at a close: an authoritative reader parked on the flight
+(`_pendingSources.has(flight)`) is re-enqueued too (§6.3a). Verdict readers
+on L2 are linked to the node directly (no companions), so the landing's own
+`insertSubs` re-runs the direct ones; the walk is for the derived ones and
+for equal-value landings, which write nothing.
+
+**Why the mark drops at the landing, not at the commit.** A verdict reader
+re-run by the landing's write reads the landing as a plain memo's reader
+does: `landStatus` clears a plain memo's pending _ahead of_ the write, so the
+reader finds a staging that will commit (final, A28) or be held (not final,
+A19 iii — `heldNotFinal`). The deferred mark drops at the same point for the
+same reason; from the landing on, the node's non-finality is its staging's,
+and a landing held by a transaction reads pending through `heldNotFinal`
+exactly as a plain memo's does. (A mark released at the commit instead made
+the reader run twice for the same `true` — found by the D1 pin.)
+
+**Display-ahead** (ruling 6) holds for the deferred verdict as for every
+verdict: a tracked `isPending(d)` re-run in a flush that parks is routed into
+the holder's verdict lane by `verdictValue`'s mark arm (`verdictRead` when
+`flushTransaction !== null`) — shown now, re-derived at the landing. Pinned
+("L2 — the verdict is display-ahead").
+
+(#3710's verdict was a predicate over its own sets read from `markWalk`,
+counted in `activeAffectsMarks`, pushed by `_repollVerdicts` onto per-node
+companions in "live" or "snap" mode. Companions, `markWalk` and the two
+repoll modes do not exist on L2; the count and the walk do, so the mark is
+one of affects.ts's own.)
### 6.3a Authoritative readers bypass the clamp (D9)
@@ -531,30 +647,91 @@ refresh(deferred)` would resolve **immediately with the stale value**. That is
a correctness bug, and it lands on exactly the §9 audience who wanted
`refresh` to work.
-Fix by precedent: `refresh` and `until` both read with
-`CONFIG_AUTHORITATIVE_READ`, which already tunnels past optimistic overrides
-(A17: "overrides invisible to the predicate"). The clamp is bypassed the same
-way — to an authoritative reader, a deferred node in flight throws
-`NotReadyError` like a plain memo, so the waiter parks and the settle walk
-delivers the landing. `until(() => deferred())` then sees only settled truth,
-which is what "authoritative" means. The check lives where
-`CONFIG_AUTHORITATIVE_READ` is already consulted in `read()` — no new
-hot-path branch.
+Fix at the two readers. `refresh`'s waiter and `until`'s predicate are the
+promise-delivery readers (ruling 8; `until` is `CONFIG_AUTHORITATIVE`, which
+already reads the truth beneath a lane's guess — A17's carve-out), so the
+bypass lives in their computes (`watch`, signals.ts) rather than in
+`read()`:
+
+- `refresh`'s waiter, after `read(node)`, throws `NotReadyError(node)` when
+ the node itself is an unanswered open flight (`unansweredFlight(node,
+null)`).
+- `until`'s predicate, after `fn()`, walks its own current dependencies
+ (respecting the recomputing tail; a store slot node hops to its family's
+ derive through `GlobalQueue._slotDerive`, since a read through the family
+ pulls the derive without linking it) for an unanswered open flight and
+ throws `NotReadyError(flight)`. Transitive: `until(() => derived() > 5)`
+ over a memo of a deferred node, and `until(() => store.items.length)` over
+ a derived store built on one, wait for the landed truth.
+
+"Unanswered" excludes a flight whose answer is staged (held by a
+transaction): authoritative reads see staged truth — which is what lets
+`yield until(...)` inside the action whose own write asked the flight settle
+on the held answer instead of deadlocking (the landing staged under the
+action is answered; the predicate re-runs in the action's flush, reads the
+staging, joins, and delivers — the action's own reader delivers the frame it
+read). Either way the waiter parks (pending on the flight as its source),
+and the flight's close releases it: a direct parker through the landing's
+own `settlePendingSource` walk, and — the walk stops at a derivation that
+never went pending — every parker through the module's `wake` (§6.3), so an
+equal-value landing beneath an unchanged derivation releases
+`until(() => derived())` too. Pinned ("L2 — D9's push"). Both checks return
+on one `Set.size` compare when no flight is open, and neither is in the
+floor; `refresh`/`until` retain the walk and nothing else of the module.
+
+**Quiet re-ask (A24).** `refresh(d)` must park the waiter and keep
+`isPending` false. A plain memo classifies the re-ask in `recompute` (the
+`reask` local → `_x._reask` once the pass goes pending); a deferred node's
+pass never goes pending once answered, so the wrapper classifies it:
+`recompute`'s pre-pass flag wipe now carries `REACTIVE_REASK` through the
+pass (the `finally` mask still drops it), and the wrapper reads it. A flight
+opened by a re-ask is open but unmarked; a new question superseding it marks
+it; a re-ask superseding a loud flight leaves the mark.
### 6.4 Module layout
-`core/deferred.ts`: exports `createDeferred`; imports `optimistic.ts` (lanes)
-and `verdict.ts`; installs any new hooks on `GlobalQueue` (`_deferredLanding?`
-or similar) following the `optimistic.ts` pattern at its module bottom.
-Re-exported from `signals.ts`. Facades: `packages/solid/src/server/signals.ts`
-(`createDeferred = createMemo`-equivalent identity, mirroring `latest`);
-`packages/solid/src/client/hydration.ts` if `createMemo` there needs a
-hydration-aware twin (likely just delegation — nothing to serialize).
+A pay-for-use module, `src/deferred.ts`, beside `affects.ts` and
+`boundaries.ts` (the public primitives layered on core). It owns the wrapper,
+the open and loud flight sets, `openFlight` / `closeFlight`, the push
+(`wake`), the D9 walk (`unansweredFlight`) and the seam sweep. It imports
+`mark`/`unmark`/`onSeam` from `affects.ts` — the sweep (a rejected flight, a
+dead node) runs from affects.ts's own seam hook (`_releaseAmbientMarks`), a
+mark-scope release like the ambient one, so no second core hook is spent on
+the same instant — so `createDeferred` retains the mark module (and not the
+verdict layer that reads it: a program that never asks `isPending` pays
+nothing for the answer; `tests/treeshake.test.ts` pins both).
+`createDeferred` lives in `signals.ts` beside `createMemo`:
+`installDeferred()` then `computed(deferredCompute(compute), options)`.
+
+What core carries, all of it in the floor:
+
+- `recompute`'s pre-pass wipe keeps `REACTIVE_REASK` (a mask constant);
+- `commitPendingNode`'s window close is gated on a staged value (§6.1);
+- `GlobalQueue._deferredLanded`, called with `?.` from `asyncWrite` ahead of
+ the landing's write (§6.3);
+- `GlobalQueue._slotDerive` (store/store.ts installs it): a slot node's
+ family derive, for D9's walk;
+- `recompute`'s T4 arm skips a `CONFIG_DEFERRED` node: a held deferred
+ node's re-pass joins nothing and does not publish the held landing
+ mainline (D11, §8.3).
+
+`verdict.ts` is untouched: the mark is read where `affects()`'s are. No
+INV-4 twin (L2 has no companions to check against an oracle).
+
+Facades: `packages/solid/src/client/hydration.ts` (`createDeferred`
+hydrates like `createMemo`, through the `_hydrateSignalLike` adapter slot
+as `createOptimistic` does, so hydrating bundles that never call it do not
+retain it) and `packages/solid/src/server/signals.ts` (`createDeferred =
+createMemo` identity, D7); exported from both `solid-js` entries.
### 6.5 Constants
-`CONFIG_DEFERRED` in `constants.ts`. Confirm remaining bit budget in
-`BITWISE_OPERATIONS.md`.
+One: `CONFIG_DEFERRED = 1 << 19`, set by `createDeferred` and read in one
+place — `recompute`'s T4 arm, where a held deferred node's re-pass takes
+neither the join nor the mainline-publish branch (D11, ruled 2026-10-04).
+Everything else is the wrapper: the state is `_loading` plus membership in
+two module-level sets and a mark count the node already carries. No
+`NodeExtension` field.
## 7. Spec propositions (to add to `SPEC-ASYNC-SEMANTICS.md` on landing)
@@ -562,17 +739,19 @@ Numbered provisionally; renumber into the A-series when adopted.
- **D1.** An initialized `createDeferred` node in flight is read as its
committed value by every tracked and untracked consumer; no
- `NotReadyError` propagates from it and it never appears in any
- transition's `_asyncReporters`.
+ `NotReadyError` propagates from it, it is never `STATUS_PENDING`, so no
+ frame observes it and no transaction is blocked by it (L2 `blocked`; was
+ "never appears in any transition's `_asyncReporters`").
- **D2.** A `createDeferred` node never serves a staged `_pendingValue`. Its
observable value changes only at (i) a global commit that includes it or
(ii) its own landing. It never observably leads a held write.
- **D3.** A `createDeferred` landing that arrives during an incomplete
unrelated transition commits and its consumers' effects flush without
waiting for that transition. Consumers that also read a source held by that
- transition see its committed value and are replayed at its commit (lane
- read gate). Exception: a consumer whose lane has merged with an in-flight
- optimistic action's lane waits for that action (§6.2, accepted coupling).
+ transition see its committed value (A29's stale-reader term) and are
+ replayed at its commit. A landing during an in-flight optimistic action is
+ an ambient commit like any other: consumers of both keep the override (A17)
+ and repaint with the new data now (§8.2).
- **D4.** `isPending(() => d())` for a deferred `d` follows A19 unchanged:
`true` while `d`'s own non-quiet flight is in flight, `false` the instant
it lands. `[isPending(() => d()), d()]` read in one scope is atomic (A10).
@@ -587,15 +766,33 @@ Numbered provisionally; renumber into the A-series when adopted.
- **D8.** `createDeferred(() => m())` over a held memo `m` behaves per D1–D6
with `m`'s `NotReadyError` as the cause of non-finality; `m`'s other
consumers are unaffected.
-- **D9.** To a `CONFIG_AUTHORITATIVE_READ` reader (`refresh`'s waiter,
- `until`'s predicate) a `createDeferred` node in flight throws
- `NotReadyError` like a plain memo. `await refresh(d)` resolves with the
- landed value, never the served stale one; `until(() => d())` evaluates only
- settled truth. (Same standing as A17's override-invisibility for
- authoritative readers.)
-- **D10.** (amends A18) `_value` changes at commit points **or at a lane
- landing**. A `createDeferred` landing under lane posture, and the sync
- derivations recomputed under it, commit directly.
+- **D9.** To a promise-delivery reader (`refresh`'s waiter, `until`'s
+ `CONFIG_AUTHORITATIVE` predicate — L2 ruling 8) a `createDeferred` node in
+ flight throws `NotReadyError` like a plain memo. `await refresh(d)`
+ resolves with the landed value, never the served stale one;
+ `until(() => d())` evaluates only settled truth — a landing staged under a
+ transaction is settled truth to it. (Same standing as A17's
+ guess-invisibility for authoritative readers.)
+- ~~**D10.** (amends A18) `_value` changes at commit points or at a lane
+ landing.~~ Withdrawn at implementation (§6.2): the landing is a plain
+ commit, not a lane landing; A18 stands unamended.
+- **D11** (ruled 2026-10-04, mizulu's report on #3710). A deferred node's
+ re-ask stages nothing and joins nothing. A held deferred node — one whose
+ landing a downstream hold staged — re-passed by a mainline write keeps
+ its held landing (the wrapper's pass returns the staging as its result;
+ nothing is restaged over the landing, nothing downstream re-derives
+ against the served value) and the write's tick stays mainline
+ (`recompute`'s T4 arm takes neither the join nor the mainline-publish
+ branch for `CONFIG_DEFERRED`). Readers of the held flight in that frame
+ are stale readers (A15): served the committed value, re-derived at the
+ hold's landing. The new flight's landing re-enters the hold (A34 (1)),
+ and the window stays open under a question the commit of an older
+ landing did not answer (D4). Limit: a derivation between the held memo
+ and the frame — a memo reading `m1()` — joins by ruling 3; L2-general,
+ not deferred-specific (a pure-L2 graph with no deferred node holds the
+ same way).
+
+Pinned in `tests/createDeferred.test.ts`, one `describe` per proposition.
## 8. Open questions
@@ -606,10 +803,10 @@ Kept as a record so the reasoning isn't relearned.
- **Lane independence** (was 8.1, "decides the whole cost"). The feared
failure — a deferred lane merging into an unrelated transition — is not a
mechanism that exists: lanes merge with lanes, transitions hold lanes only
- through observed reporters, and a deferred node is never a reporter. The
- lane read gate already gives lane readers lag-never-lead against held
- sources. Full account in §6.2. Cost collapsed from "small or medium" to
- "small, with one accepted merge case."
+ through observed reporters, and a deferred node is never a reporter. Cost
+ collapsed from "small or medium" to "small, with one accepted merge case."
+ _Superseded at implementation:_ the landing does not use lanes at all
+ (§6.2, #3479 drift), so the question is moot and the merge case is gone.
- **`until` / `refresh`** (was 8.4, "verify"). Not a verification — a bug.
`refresh(deferred)` would resolve with the stale value because the waiter
relies on the read parking. Fix is D9 (authoritative readers bypass the
@@ -647,55 +844,77 @@ the recommended pattern, not a special case.
**Fix: the `affects()` mechanics, verdict channel only.** `affects()` is
already the "doesn't hold above, shows pending below, without recompute"
-primitive, and it is built as two channels:
-
-- _Pull_: a mark is only a count on the node (`_affectsCount`); coverage of
- everything derived from it is computed by `markWalk` — dep-graph
- reachability through current deps, hopping store firewalls (exactly
- leaf → firewall → memo). Nothing is stored downstream, so rewires and
- mid-window recomputes cannot strand or strip it.
-- _Push_: `_repollVerdicts(node)` re-derives every materialized companion
- downstream on registration/release, on the companion's own lane so the
- wake escapes an incomplete transition's effect stash (#2887).
+primitive (on L2, ruling 9 — rebuilt transaction-inert), and it is built as
+two channels:
+
+- _Pull_: a mark is only a count on the node (`_x._marks`); coverage of
+ everything derived from it is computed at probe time by `marked`
+ (affects.ts) — dep-graph reachability through the probed node's current
+ dependencies; a read through a derived store's family pulls the derive
+ inside the verdict window (`pullFamily`), which is how leaf → derive → memo
+ is covered. Nothing is stored downstream, so rewires and mid-window
+ recomputes cannot strand or strip it.
+- _Push_: the verdict readers downstream (`CONFIG_VERDICT`, linked to the
+ node directly — L2 has no companions) re-derive on registration and
+ release, through derivations, stopping at effects (`repoll`).
A deferred node in flight is a mark of this kind, with one structural
-difference: `affects()` marks are transaction-owned refcounts released at
-settle/revert, and a deferred node never has a transaction to end. Its mark
-is therefore **node state whose lifecycle is the flight** — a cold-slot flag
-(`_x._deferredFlight`, name TBD), not a count. Note that `_inFlight` itself
-cannot serve: it is never nulled at an async landing (it persists as the
-supersession identity token that `asyncWrite` / `handleError` compare
-against), so "`_inFlight` non-null" would read pending forever after the
-first landing.
-
-Lifecycle: **set** when `handleAsync` takes the served-committed branch for
-an initialized `CONFIG_DEFERRED` node with an async result (after the
-sync-resolve check, so a synchronously-resolving promise never sets it — A10);
-**cleared** at the top of `asyncWrite` and `handleError`, both already
-identity-gated so a superseded flight's landing cannot clear the flag the
-newer flight owns. By case: lands → cleared, repoll. Rejects → cleared,
-`STATUS_ERROR`; error outranks the verdict (A16) and throws to ``.
-Superseded → stays set until the _new_ flight lands. Hangs → stays set, same
-as any async memo's `STATUS_PENDING`. Quiet re-ask (`_reask`, A24) → set but
-reads quiet. No release step, nothing to leak; the landing repoll is the
-"release."
-
-Then: `markWalk` looks for `CONFIG_DEFERRED && _x._deferredFlight && !quiet`
-alongside `_affectsCount`; flight start and landing call `_repollVerdicts`.
-Gate stays one integer compare via a global in-flight-deferred counter beside
-the affects counter (incremented/decremented with the flag).
-`isPending(store)` (A23) takes the same walk from the firewall.
-
-Deliberately **not** copied from `affects()`: `notifyMarkBoundaries` (the
-visual channel that holds `` fallbacks / reveal order) and the
-`_affectsNodes` transaction registration. "Doesn't hold above" is precisely
-"skip those two." Deferred is the verdict channel of `affects` without its
-boundary channel.
-
-**Push placement.** The flight-start repoll sits where the flag is set; the
-landing repoll rides the `_syncCompanions` call already in `asyncWrite`'s lane
-branch. One check, which `affects()` already answers for its own
-register/release pair: no double-fire between the two pokes.
+difference: `affects()` marks are listed with a transaction (released at its
+landing) or the ambient list (released at the seam), and a deferred node
+never has a transaction to end. Its mark is therefore **a mark whose scope
+is the flight**: the same count on the node, registered and released by the
+module that owns the flight. Note that `_inFlight` itself cannot serve as
+the flight's identity for this: it is never nulled at an async landing (it
+persists as the supersession identity token that `asyncWrite` /
+`handleError` compare against), so "`_inFlight` non-null" would read
+pending forever after the first landing; the open-flight set is the
+identity.
+
+Lifecycle (as landed on L2): **opened** by `openFlight`, called by the
+wrapper when an answered node's pass leaves a flight in the air (an
+async-shaped result `handleAsync` did not land synchronously, or a thrown
+`NotReadyError`, D8) — marked unless a quiet re-ask; a loadingValue node's
+first flight is the A27 window and not a flight (verdict-quiet). **Closed**
+by `closeFlight` — `unmark` and the push — from the landing hook
+(`asyncWrite`, ahead of the landing's write, §6.3), from the wrapper (a sync
+answer, or a flight found closed or errored at the next pass), and from the
+seam (a rejected flight, a node disposed mid-flight). `asyncWrite` is
+identity-gated, so a superseded flight's landing cannot close the flight the
+newer one owns. By case: lands → closed ahead of the write, verdict readers
+re-derive, waiters released (D9); from there the node's non-finality is its
+staging's (a landing held by a transaction reads pending through
+`heldNotFinal`, A19 iii, as a plain memo's does). Rejects → `STATUS_ERROR`,
+throws to ``; the seam of the flush the error's readers schedule
+closes the flight, so the error outranks the verdict from there (D6) — a
+verdict reader re-run in that flush's heap read the mark once more over the
+same `true` it already showed, and re-derives at the close. Superseded →
+stays open until the _new_ flight lands. Hangs → stays open, same as any
+async memo's `STATUS_PENDING`. Quiet re-ask (A24) → open, unmarked. Disposed
+→ closed at the next seam that runs. A disposal inside a flush gets that
+flush's seam; one outside a flush schedules nothing (`disposeChildren`
+forces a seam only for a held flight or a stale frame reader), so the dead
+node's mark outlives it until the next write's flush — a pending verdict
+over a value that will never change, bounded by the application's next
+flush, taken over the 5 B the dispose clause cost. Nothing leaks: the set is
+the lifecycle, the count follows it.
+
+Deliberately **not** copied from `affects()`: the scope lists and the
+boundary channel. A mark holds nothing on L2 either (`blocked` never sees
+one), so "doesn't hold above" is the channel's own property; what the
+deferred flight adds is a release schedule that is the flight's rather than
+a transaction's or the seam's.
+
+**Push placement.** Both pushes sit with the set: flight start in
+`openFlight`, close in `closeFlight`. On L2 the verdict readers are plain
+subscribers of the node, so order against the pending-node queue is
+irrelevant (no companion publishes a staged verdict twice). The push does
+not hop a derived store's family — `repoll` and `wake` walk subscribers, and
+a family's leaves do not subscribe to its derive — so a _tracked_ probe
+through a derived store's leaves is re-derived by the landing's leaf changes,
+not by the flight's open or an equal-value close; an untracked probe is exact
+either way (the pull). The same holds for `affects()` on L2 (a memo mark
+through a derived store); if the maintainer wants the push to hop families
+it is one derive → family back-reference in `repoll`, for both.
**Decision (revised 2026-09-08).** In the initial implementation, not a
follow-up: the docs' own recommended composition would otherwise show a
@@ -703,28 +922,28 @@ visible hole (`isPending(() => store.items.length)` false during refetch).
`isPending(rows)` on the memo itself needs only the §6.3 clause and works
regardless.
-### 8.2 Lane merge with an in-flight optimistic action — **decided: accept, it is the correct semantics**
-
-§6.2's case: an effect reading a deferred node and an in-flight optimistic
-action's override converges, merges lanes, and waits on the action. First
-recorded as an accepted coupling; on examination it is the right outcome, not
-a tolerated one. The todo case: two rows from deferred `rows`; the user
-optimistically toggles row A inside an action still awaiting the server; a
-deferred refetch lands mid-action.
-
-- **Row B repaints now.** Its effect reads only deferred-derived data; its
- lane never converges with the action's.
-- **Row A waits, and repaints once.** Its effect reads both, so it merges and
- waits for the action — the one consumer the user is actively acting on,
- where "wait for confirmation" is the expected feel. And per A18 ("the
- correction reveals atomically with that merge") the override drop and the
- landing's new data land in the **same paint** at action settle. The
- exemption alternative would paint twice (landing under the override, then
- the revert).
-
-One convergence rule, one paint, on the one row where it matters. Pinning
-test: assert B repaints on the landing; A shows the override throughout and
-repaints exactly once at settle. Re-open only on a field report.
+### 8.2 A landing during an in-flight optimistic action — **resolved at implementation: A17, no lane**
+
+The sketch's case: an effect reading a deferred node and an in-flight
+optimistic action's override converges, merges lanes, and waits on the
+action; it was accepted as "one paint at settle." With the plain landing path
+(§6.2) there is no lane to merge. The landing is an ambient commit — the same
+event as a signal written mid-action — and the engine already rules it: the
+consumer of both re-derives with the override intact (A17) and repaints with
+the new data now; at settle the override drops over the landed data. The
+todo case: two rows from deferred `rows`; the user toggles row A inside an
+action still awaiting the server; the refetch lands mid-action.
+
+- **Row B repaints now.** It reads only deferred-derived data.
+- **Row A repaints now too, toggled.** `rows-1:false → rows-1:true →
+rows-2:true`, then `rows-2:false` at settle. The committed (un-toggled)
+ value is never shown while the action runs.
+
+Two paints on A instead of the sketch's one — but the second is the revert
+the action owes regardless, and the data no longer waits on an unrelated
+server round-trip, which is the primitive's whole point. Pinned. (The
+settle's run count is the lane engine's — identical for a plain signal
+written mid-action — and is not this primitive's to pin.)
### 8.3 Smaller
@@ -737,13 +956,62 @@ repaints exactly once at settle. Re-open only on a field report.
plain memo's, so quiet/loud classification should fall through unchanged.
Confirm the quiet re-ask keeps `isPending(() => d())` false (A24) while
still resolving the waiter.
-- `CONFIG_DEFERRED = 1 << 19` (`CONFIG_*` currently tops out at `1 << 18`;
- fits).
+- `CONFIG_DEFERRED = 1 << 19`. Not spent at first (the brand was the
+ compute wrapper alone); spent by D11 (2026-10-04) — `recompute`'s T4 arm
+ runs before the wrapper and needs the brand on the node (§6.5).
+- ~~**Known edge: a synchronously-resolved refetch.**~~ Closed on L2 (§6.1):
+ the wrapper registers the flight through `handleAsync` itself and
+ classifies the outcome, so a thenable that resolves synchronously (a
+ `MockPromise`-style value) never opens a flight — no mark, no stale
+ verdict downstream. Pinned ("L2 — a refetch that resolves synchronously
+ is a landing").
+- **Error precedence timing.** A rejection closes the flight at the seam of
+ the flush its readers schedule (``'s output and verdict readers
+ re-derive on an error, and schedule), not inside `handleError`: a hook
+ there would be a third floor call for a transient — a verdict reader
+ re-run in that flush's heap reads the mark once more over the `true` it
+ already showed, then re-derives `false` at the close. At rest, an errored
+ deferred node is never pending (D6, A16's "error outranks").
+- **A held deferred node re-passed by a mainline write** — ruled
+ 2026-10-04 (D11; mizulu's report on #3710, same on the PR and on L2).
+ Shape: `count` → `d1 = createDeferred` → `m1 = createMemo(async)`, both
+ read by `{}` holes under ``; `count` also read by a button
+ outside. `d1`'s landing re-runs `m1`, which pends under its reader: an
+ ordinary hold (A15), and the landing — an ordinary commit — is staged
+ with it (`CONFIG_HELD`). A later click (a mainline write to `count`,
+ held by no one) re-passed `d1`, and `recompute`'s "a pass over a held
+ derivation joins its transaction" (T4) joined the whole tick: the button
+ parked until `m1` landed — the report. The same pass restaged the served
+ committed value over the held landing, so `m1` re-fetched against the
+ old input and the landing was lost until a later flight re-landed it
+ (5 `m1` runs, two `m1:done(0)`). Two readings were spiked on evidence:
+ (1) the join stands and only the restaging is fixed — the button still
+ waits; (2) a lagging question is not an answer — the re-pass joins
+ nothing. The maintainer ruled for (2). Landed in two commits: the common
+ part (`fix(createDeferred): a re-ask over a held landing keeps it; the
+window stays open under a newer question` — deferred-module only, required
+ under either reading: without it the restaged value dirties `m1`, a held
+ plain memo, which joins by T4 itself) and the ruling (`signals(
+createDeferred): a lagging question is not an answer — a held deferred
+re-pass joins nothing` — `CONFIG_DEFERRED`, the T4 arm). Cost: +12 B
+ minified in the core floor; no test moved. **Limit, L2-general:** a
+ derivation between the held memo and the frame (the first signals
+ harness put the boundary's text in a memo reading `d1()` and `m1()`)
+ re-passes, reads the held pending `m1` and joins by ruling 3 — the tick
+ parks with no deferred node involved at all (pinned beside a pure-L2
+ control). The ruling is reachable on the playground's real graph because
+ the `{}` holes are render effects — stale readers, which do not join.
+ Pinned: `tests/createDeferred.test.ts` ("a re-pass over a held landing
+ keeps it (the common part)", "… commits mainline; the held landing stands
+ (D11)", the derivation limit and the pure-L2 control) and the user's
+ exact graph in `packages/web/test/create-deferred-mizulu-3710.spec.tsx`.
Not open — rejected, recorded so they aren't re-proposed: a ``
boundary (§4.1: ambient scope is the theme-fanout hazard; not planned) and a
-`createMemo(fn, { deferred })` option (§4.6: puts the lane dependency in the
-floor bundle; the tree-shaking argument is decisive).
+`createMemo(fn, { deferred })` option (§4.6; the lane-dependency half of that
+argument fell with §6.2 — what remains is that a separate name is the
+teachable unit (§2.3, §9's "delete `Deferred`" step) and keeps the door open
+to relocation, which is how it landed (§6.4).
## 9. Migration role (raises the priority)
@@ -792,3 +1060,72 @@ Doc consequences (owed regardless of this primitive, sharper with it):
(the literal translation).
- The effect-write-back discouragement stays; `createDeferred` is what makes
it fair to keep.
+
+## 10. Review brief (for the maintainer, 2026-10-04)
+
+Six places to read first, each with the one question it should answer.
+
+1. `src/core/async.ts` : `asyncWrite` — the `GlobalQueue._deferredLanded?.(el)`
+ call after the `REACTIVE_DIRTY` drop, ahead of `landStatus`. _Is dropping
+ the mark before the landing's write the right ordering, so the verdict
+ readers that write re-runs read the landing as final (D1/D4) — and is
+ "after the dirty-drop" right, so a superseded landing never touches the
+ mark?_
+2. `src/deferred.ts` : `openFlight` + `src/core/scheduler.ts` : `settle` /
+ `commitPendingNode` — the wrapper calls `queuePendingNode(el)` with
+ nothing staged, and `commitPendingNode` now closes `_loading` only under
+ `_pendingValue !== NOT_PENDING`. _Is a pending node with no staged value
+ a legitimate input to the seam's hold decision ("never leads", D2), and
+ does the gated close leave any non-deferred loading-window node open that
+ used to close?_
+3. `src/affects.ts` : `mark` / `unmark` / `onSeam` → `_releaseAmbientMarks`,
+ read by `src/core/verdict.ts` : `verdictValue`'s `GlobalQueue._marked`
+ arm. _Is reusing the `affects()` mark count as `createDeferred`'s verdict
+ (scope = the flight; no scope lists, no boundary channel) sound,
+ including the sweep running inside affects' release hook?_
+4. `src/store/store.ts` : `GlobalQueue._slotDerive` + `src/deferred.ts` :
+ `unansweredFlight` (consumed by `until` and the `refresh` waiter in
+ `src/signals.ts`). _Is hopping a slot node to its family derive the
+ correct and only hole in D9's authoritative dep walk, and is it
+ acceptable that `until` throws `NotReadyError` for an equal-value
+ derived answer?_
+5. `src/core/scheduler.ts` : `commitPendingNode` — the one behaviour change
+ reachable without calling `createDeferred`: a loading-window node
+ committed with nothing staged keeps its window open (before, the sweep
+ closed it unconditionally). _Can a plain async memo reach
+ `commitPendingNode` with `_loading` set and nothing staged, and if so is
+ "window stays open" what A27 wants there?_
+6. `src/core/core.ts` : `recompute`, the T4 arm (`if (tx._lane || el._config
+& CONFIG_DEFERRED) {} else if (…) joinPassTx(tx) else if (…) publish
+mainline`) and `src/deferred.ts` : `deferredCompute`'s `async &&
+el._loading` arm (`result = el._pendingValue` over a held landing) +
+ `seam()`'s window re-open. The ruling (D11, 2026-10-04): _a held
+ deferred node's re-pass joins nothing and keeps its held landing; is the
+ brand check in the T4 arm the right and only core seam, and is the
+ window re-open at the sweep sound against every other path that clears
+ `_loading`?_ Landed as two commits on this branch — the common part
+ (`fix(createDeferred): a re-ask over a held landing keeps it; the window
+stays open under a newer question`, deferred-module only) and the ruling
+ (`signals(createDeferred): a lagging question is not an answer — a held
+deferred re-pass joins nothing`). §8.3 ("A held deferred node re-passed
+ by a mainline write") has the evidence and the L2-general limit;
+ pins: `tests/createDeferred.test.ts` (the common part; "… commits
+ mainline; the held landing stands (D11)"; the derivation limit; the
+ pure-L2 control) and `packages/web/test/create-deferred-mizulu-3710.spec.tsx`
+ (the user's exact graph).
+
+Also worth a glance: `src/core/core.ts` : `recompute` keeps `REACTIVE_REASK`
+through the pass (the wrapper classifies quiet re-asks by it);
+`src/core/owner.ts` is comment-only after the dispose-clause cut.
+
+**`core/` footprint** (everything else is the module, the store hop, the
+public surface, tests and docs):
+
+- `src/core/async.ts` — the `_deferredLanded` hook call (4 lines);
+- `src/core/constants.ts` — `CONFIG_DEFERRED = 1 << 19`;
+- `src/core/core.ts` — the `REACTIVE_REASK` mask in `recompute`'s pre-pass
+ wipe (1 code line); the `CONFIG_DEFERRED` test in the T4 arm (D11);
+- `src/core/owner.ts` — comment only;
+- `src/core/scheduler.ts` — two `declare static` hook slots
+ (`_slotDerive`, `_deferredLanded`); `commitPendingNode`'s `_loading` close
+ gated on a staged value.
diff --git a/packages/signals/src/affects.ts b/packages/signals/src/affects.ts
index 6b7252beba..89b1f0decc 100644
--- a/packages/signals/src/affects.ts
+++ b/packages/signals/src/affects.ts
@@ -106,10 +106,27 @@ function register(node: Marked): void {
}
repoll(node);
}
-function mark(node: Marked): void {
+/** One mark on the node, outside any scope list: `createDeferred`'s flight
+ * (deferred.ts) registers one at its start and `unmark`s it at its close —
+ * a mark whose window is the flight, released by the module that owns the
+ * flight (the push half is its own). */
+export function mark(node: Marked): void {
ext(node)._marks++;
active++;
}
+export function unmark(node: Marked): void {
+ node._x!._marks--;
+ active--;
+}
+/** A scope sweep run at every seam, ahead of the ambient release: a module
+ * whose marks' window is neither a transaction's nor the flush's
+ * (deferred.ts: the flight's) decides there which of its marks ended. The
+ * seam hook is this module's (`_releaseAmbientMarks`); a second core hook
+ * for the same instant would cost the floor. */
+const sweeps: Array<() => void> = [];
+export function onSeam(sweep: () => void): void {
+ sweeps.push(sweep);
+}
/**
* Declares that in-flight work will change the targeted data: the source —
@@ -181,6 +198,7 @@ GlobalQueue._marked = el => active !== 0 && marked(el, new Set());
GlobalQueue._mark = mark;
GlobalQueue._releaseMarks = release;
GlobalQueue._releaseAmbientMarks = parked => {
+ for (let i = 0; i < sweeps.length; i++) sweeps[i]();
if (ambient.length === 0) return;
// The flush parked: the declaration's window is the frame's — the marks
// are its transaction's ("nothing async below ⇒ no window").
diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts
index 3ac53a1e97..ddb5d654b5 100644
--- a/packages/signals/src/core/async.ts
+++ b/packages/signals/src/core/async.ts
@@ -447,6 +447,10 @@ export function handleAsync(
// skip this stale async result — the upcoming flush will recompute the node
// with the new value, creating a fresh Promise that supersedes this one.
if (el._flags & REACTIVE_DIRTY) return;
+ // A `createDeferred` flight landed (deferred.ts): its mark drops ahead of
+ // the write, as `landStatus` clears a plain memo's pending ahead of it —
+ // the verdict readers the write re-runs read the landing as final.
+ GlobalQueue._deferredLanded?.(el);
const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
landStatus(el);
// Attribution hook: lets the engine snapshot state before the landing
diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts
index 18cac42f1c..3ad715b981 100644
--- a/packages/signals/src/core/constants.ts
+++ b/packages/signals/src/core/constants.ts
@@ -145,6 +145,11 @@ export const CONFIG_REDERIVE = 1 << 15;
* pass no lane's, and the truth landing wakes it even when it confirms the
* guess (the one case ordinary subscribers are not told — A17's silence). */
export const CONFIG_AUTHORITATIVE = 1 << 17;
+/** A `createDeferred` node (deferred.ts). Read in one place: `recompute`'s
+ * T4 arm — a pass over a held deferred node is a lagging question, not an
+ * answer: it joins no transaction and does not publish the held landing
+ * mainline (reading 2 of create-deferred.md 8.3). */
+export const CONFIG_DEFERRED = 1 << 19;
// Presence bits (stage-3 hot-path monomorphism, DESIGN-PATCH-CHANNEL §11b):
// optional per-node slots (_overrideValue, _pendingSignal/_latestValueComputed,
// _snapshotValue, _optimisticLane) are NOT part of every node's hidden class —
diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts
index dc755f8e0c..3e712a37ca 100644
--- a/packages/signals/src/core/core.ts
+++ b/packages/signals/src/core/core.ts
@@ -11,6 +11,7 @@ import {
$REFRESH,
CONFIG_AUTO_DISPOSE,
CONFIG_CHILDREN_FORBIDDEN,
+ CONFIG_DEFERRED,
CONFIG_FRESH_READ,
CONFIG_GUESS,
CONFIG_VERDICT,
@@ -324,7 +325,13 @@ export function recompute(el: Computed, create: boolean = false): void {
// Held by a blocked lane: the frame joins nothing — a lane never holds
// a sync write (#3460). The pass is the lane's if it reads the lane's
// world (`read`), and has left it otherwise.
- if (tx._lane) {
+ // A deferred node held because a downstream hold staged its landing
+ // (CONFIG_DEFERRED, deferred.ts): its re-pass is a lagging question,
+ // not an answer — it stages nothing (the wrapper returns the held
+ // landing as its result) and joins nothing, so a mainline write that
+ // reaches it commits mainline; the landing stays staged for the
+ // hold's reveal and the new flight's landing re-enters it (A34 (1)).
+ if (tx._lane || el._config & CONFIG_DEFERRED) {
} else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx);
else if (
tx !== flushTransaction &&
@@ -379,7 +386,9 @@ export function recompute(el: Computed, create: boolean = false): void {
// frame, and the splice/reawaken paths key off it. A zombie reruns for
// live writes until the commit that disposes it, so every per-pass wipe
// — here, the finally below, updateIfNecessary — carries it (#3543).
- el._flags = REACTIVE_RECOMPUTING_DEPS | (el._flags & REACTIVE_ZOMBIE);
+ // REACTIVE_REASK stays readable for the pass (`createDeferred`'s wrapper
+ // classifies its flight by it, deferred.ts); the finally below drops it.
+ el._flags = REACTIVE_RECOMPUTING_DEPS | (el._flags & (REACTIVE_ZOMBIE | REACTIVE_REASK));
el._time = clock;
// The pass's previous value: its staging, else a lane node's lane value
// when it has one (lanes.ts), else the committed one.
diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts
index f745e143e1..83470e48ea 100644
--- a/packages/signals/src/core/owner.ts
+++ b/packages/signals/src/core/owner.ts
@@ -84,7 +84,9 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo
// A death that can unblock the future (L2, `futureBlocked`): a held
// flight's — a transaction's or a lane's (its `_x._transaction`) — or a
// stale reader's (REACTIVE_FRAME_READ — a frame that derived from one).
- // Nothing else re-examines a dead node, so make sure a seam comes.
+ // Nothing else re-examines a dead node, so make sure a seam comes. (A
+ // `createDeferred` flight dying open is swept at the next seam that
+ // runs, deferred.ts; a death outside a flush does not schedule one.)
if (
((node as Computed)._statusFlags & STATUS_PENDING &&
(node as Computed)._x?._transaction != null) ||
diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts
index af6d0907f1..e9a6924a4a 100644
--- a/packages/signals/src/core/scheduler.ts
+++ b/packages/signals/src/core/scheduler.ts
@@ -636,6 +636,15 @@ export class GlobalQueue implements IQueue {
declare static _applyGuesses: ((parent: Transaction | null) => void) | undefined;
/** A slot node's truth is in flight (its family's derive — store/store.ts). */
declare static _slotFlight: ((n: Signal) => boolean) | undefined;
+ /** A slot node's family derive (store/store.ts): the node a read through
+ * the family pulls without linking — `createDeferred`'s authoritative
+ * walk hops it (deferred.ts). */
+ declare static _slotDerive: ((n: Signal) => Computed | null) | undefined;
+ /** `createDeferred` (deferred.ts): the node's flight landed (`asyncWrite`,
+ * ahead of the landing's write, as `landStatus` is): its mark releases,
+ * the readers parked on it wake. (Its seam sweep rides affects.ts's
+ * `_releaseAmbientMarks` — `onSeam`.) */
+ declare static _deferredLanded: ((el: Computed) => void) | undefined;
/** The value a slot node's guess covered: its committed value — a chained
* link's refreshed from the inner store, whose commits it never learned
* while the guess served the reads (store/store.ts, §7b). */
@@ -1172,6 +1181,12 @@ export function commitPendingNode(n: Signal): void {
// the reveal. (The sweep of a node still in flight is not a landing.)
// The A28 stash goes with it.
if (c._x != null) ((c._x._reask = false), (c._x._flushed = NOT_PENDING));
+ // The committed value is the first observable answer for a loading-window
+ // node — the window closes here, not at compute time (#2990). Gated like
+ // the re-ask clear: a window with nothing staged is still in flight — a
+ // `createDeferred` flight queued for the seam's hold decision (D2) and
+ // committed by a sweep, or held and landed without it (deferred.ts).
+ c._loading = false;
// An effect with a staged value was born held (L2): its first run is
// this commit's, not its creation's (A29) — queue it now.
if ((n as any)._type && (n as any)._type !== EFFECT_TRACKED) {
@@ -1182,10 +1197,6 @@ 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;
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
diff --git a/packages/signals/src/deferred.ts b/packages/signals/src/deferred.ts
new file mode 100644
index 0000000000..42d70bf2ba
--- /dev/null
+++ b/packages/signals/src/deferred.ts
@@ -0,0 +1,266 @@
+/**
+ * `createDeferred` (docs/create-deferred.md): an async memo that may lag the
+ * global clock but never leads it — on L2, the hold model.
+ *
+ * Pay-for-use by construction: the clamp is a compute wrapper over the
+ * commit-#0 loading window core already has (A27). Each pass of an answered
+ * node re-opens the window (`_loading = true`) before calling the user's
+ * compute, so core serves the committed value for an unready result
+ * (`handleAsync`'s serve branch, `recompute`'s `parkLoadingWindow`) exactly
+ * as it serves a loadingValue window: the pass neither throws nor goes
+ * `STATUS_PENDING`, so no frame observes a flight, no flush parks on it, and
+ * the write that re-asked the node commits with its tick (D1). Every site
+ * that makes an answer observable closes the window again (`asyncWrite`'s
+ * equal-value landing, `commitPendingNode` for a staged one, the sync-resolve
+ * paths), so the window IS the flight: open while the node is serving a
+ * previous answer with a newer one in flight.
+ *
+ * The verdict (D4) rides the `affects()` mark channel (affects.ts, A24): an
+ * open loud flight is a mark on the node — a count `isPending`'s read finds
+ * directly or through the probed node's dependencies (`GlobalQueue._marked`),
+ * and a pull through a derived store's family (`pullFamily` reads the derive
+ * inside the window). The mark's scope is the flight: registered at its
+ * start, released at its landing (`GlobalQueue._deferredLanded`, ahead of
+ * the landing's write) or — a rejection, a death — at the seam (affects.ts's
+ * `onSeam`), each time re-deriving the verdict readers downstream so the
+ * flip is pushed where no value notifies anyone.
+ *
+ * Never leads (D2): a flight asked inside a flush is queued as a pending node
+ * of that flush with nothing staged. If the flush parks — a sibling's flight
+ * held the write the node was re-asked with — the seam holds the node with
+ * the transaction (`holdNode`) like any pending node, and its landing's
+ * `setSignal` re-enters the hold (A34 (1)): the answer reveals with the
+ * write, never ahead of it. If the flush commits, the sweep's commit of an
+ * unstaged node is a no-op (`commitPendingNode` closes the window only for a
+ * staged value). Nothing lane-shaped, nothing new in `read()`.
+ *
+ * Authoritative readers (D9) — `refresh`'s waiter, `until`'s predicate — are
+ * served the committed value like everyone else, so they ask this module
+ * whether the node (or, for `until`, anything its pass derives from) is an
+ * unanswered flight and park on it (`NotReadyError(flight)`); the flight's
+ * close wakes them. A flight whose answer is staged under a hold is answered:
+ * authoritative reads see staged truth (A17's carve-out).
+ */
+import { mark, onSeam, unmark } from "./affects.js";
+import { handleAsync, isThenable } from "./core/async.js";
+import {
+ CONFIG_SLOT_NODE,
+ CONFIG_VERDICT,
+ NOT_PENDING,
+ REACTIVE_DISPOSED,
+ REACTIVE_REASK,
+ REACTIVE_RECOMPUTING_DEPS,
+ STATUS_ERROR,
+ STATUS_UNINITIALIZED
+} from "./core/constants.js";
+import { context, untrack } from "./core/core.js";
+import { NotReadyError } from "./core/error.js";
+import { enqueueSub } from "./core/heap.js";
+import { GlobalQueue, globalQueue, queuePendingNode, schedule } from "./core/scheduler.js";
+import type { Computed, Signal } from "./core/types.js";
+
+type Node = Signal | Computed;
+
+/** Open flights: answered nodes serving a previous answer with a newer one in
+ * flight (`_loading` re-opened by the wrapper). */
+const open = new Set>();
+/** The open flights that carry a mark — asked by a new question. A quiet
+ * re-ask (`refresh()`, A24) is open but unmarked. */
+const loud = new Set>();
+
+/** D9: is `el` itself an unanswered open flight? A staged answer (held by a
+ * transaction) counts as answered — authoritative reads see staged truth; an
+ * errored one answered with its error (D6). */
+function unanswered(el: Node): boolean {
+ const c = el as Computed;
+ return (
+ c._loading === true &&
+ open.has(c) &&
+ el._pendingValue === NOT_PENDING &&
+ !(c._statusFlags & STATUS_ERROR)
+ );
+}
+
+/**
+ * D9: the flight an authoritative reader must park on — `el` itself, or
+ * (with a `seen` set) one reachable through its current dependencies, hopping
+ * a store slot node to its family's derive (a read through the family pulls
+ * the derive without linking it). Mid-recompute, only the validated prefix of
+ * the dependency list is this pass's. One `Set.size` compare when no flight
+ * is open.
+ */
+export function unansweredFlight(
+ el: Node,
+ seen: Set | null = new Set()
+): Computed | undefined {
+ if (open.size === 0) return;
+ if (unanswered(el)) return el as Computed;
+ if (seen === null || seen.has(el)) return;
+ seen.add(el);
+ if (el._config & CONFIG_SLOT_NODE) {
+ const fw = GlobalQueue._slotDerive?.(el as Signal);
+ return fw != null ? unansweredFlight(fw, seen) : undefined;
+ }
+ const c = el as Computed;
+ const tail = c._flags & REACTIVE_RECOMPUTING_DEPS ? c._depsTail : undefined;
+ if (tail === null) return;
+ for (let d = c._deps ?? null; d !== null; d = d._nextDep) {
+ const f = unansweredFlight(d._dep, seen);
+ if (f) return f;
+ if (d === tail) break;
+ }
+}
+
+/** The readers downstream of `node` whose answer changed with no value
+ * notifying anyone re-derive: verdict readers (`isPending`/`latest` — the
+ * mark's push half, as affects.ts's `repoll`), and — at a close — the
+ * authoritative readers parked on the flight (D9). Through derivations,
+ * stopping at effects. */
+function wake(node: Node, flight: Computed | null, seen = new Set>()): void {
+ for (let s = node._subs; s !== null; s = s._nextSub) {
+ const sub = s._sub;
+ if (seen.has(sub)) continue;
+ seen.add(sub);
+ if (
+ sub._config & CONFIG_VERDICT ||
+ (flight !== null && sub._x?._pendingSources?.has(flight) === true)
+ )
+ enqueueSub(sub);
+ if (!(sub as any)._type) wake(sub, flight, seen);
+ }
+}
+
+function openFlight(el: Computed, reask: boolean): void {
+ if (!open.has(el)) {
+ open.add(el);
+ // A new question marks the node (A24): its verdict readers re-derive now.
+ // A quiet re-ask is open — the waiters park on it — but unmarked.
+ if (!reask) {
+ loud.add(el);
+ mark(el);
+ wake(el, null);
+ }
+ } else if (!reask && !loud.has(el)) {
+ // A new question supersedes a quiet re-ask: the flight turns loud. A
+ // re-ask superseding a loud flight stays loud (A19 exc. 2: the re-ask
+ // does not launder the new question).
+ loud.add(el);
+ mark(el);
+ wake(el, null);
+ }
+ // Never leads (D2): a flight asked inside a flush belongs to that tick. As
+ // a pending node of the flush, the seam holds it with the transaction the
+ // flush parks into (a sibling's flight holding the write it was asked
+ // with), and its landing re-enters the hold. A committing flush leaves an
+ // unstaged node untouched.
+ if (globalQueue._running) queuePendingNode(el);
+}
+
+/** The flight landed (`asyncWrite`, ahead of the write — as `landStatus`
+ * clears a plain memo's pending ahead of it, so the verdict readers the
+ * write re-runs read the landing as final, A28), or the wrapper saw a sync
+ * answer, or the seam found it rejected (D6: the error is the answer) or
+ * dead: drop the mark, re-derive the verdicts it covered, release the
+ * authoritative readers parked on it. From here the node's non-finality is
+ * its staging's (A19 cause iii: a landing held by a transaction reads
+ * pending through `heldNotFinal`, exactly as a plain memo's does). */
+function closeFlight(el: Computed): void {
+ if (!open.delete(el)) return;
+ if (loud.delete(el)) unmark(el);
+ wake(el, el);
+ schedule();
+}
+
+/** The seam (affects.ts's `onSeam`, run from its release hook after the
+ * landings) — the close for what no landing reports: a rejected flight
+ * (`handleError` propagates the error, and the flush its readers schedule
+ * brings the seam — the error outranks the verdict from there; a verdict
+ * reader that ran in that flush's heap read the mark once more and
+ * re-derives), a node disposed mid-flight (its landing is dropped by
+ * identity; a disposal outside a flush schedules no seam, so the mark holds
+ * until the next flush that runs one), a window closed with no `asyncWrite`
+ * (a stream's buffered yield). */
+function seam(): void {
+ if (open.size === 0) return;
+ for (const el of open)
+ if (el._statusFlags & STATUS_ERROR || el._flags & REACTIVE_DISPOSED) closeFlight(el);
+ // A commit closed the window under an open question: a held landing —
+ // the answer to the question BEFORE this one, landed and closed at its
+ // `asyncWrite` — committed with its hold while the re-ask is in the
+ // air (`commitPendingNode` closes a loading window on any staged value,
+ // A27). The question stands, and the window with it: `isPending` is
+ // true while the node's own flight is in flight (D4).
+ else if (!el._loading) el._loading = true;
+}
+
+/** The clamp: a compute wrapper that keeps an answered node's window open. */
+export function deferredCompute(compute: (prev: T) => T): (prev: T) => T {
+ let answered = false;
+ return prev => {
+ const el = context as Computed;
+ // A flight whose answer became observable at a site the seam has not
+ // swept yet (a sync-resolved thenable), or that answered with its error
+ // (D6), is closed before this pass opens the next.
+ if (!el._loading || el._statusFlags & STATUS_ERROR) closeFlight(el);
+ // Answered = initialized with the window closed. A loadingValue node is
+ // born with the window open: its first flight is the A27 window
+ // (verdict-quiet), and it answers when that flight lands.
+ answered ||= !el._loading && !(el._statusFlags & STATUS_UNINITIALIZED);
+ if (!answered) return compute(prev);
+ // `recompute` carries the re-ask flag through the pass for this read.
+ const reask = (el._flags & REACTIVE_REASK) !== 0;
+ el._loading = true;
+ let result: T;
+ let async = false;
+ try {
+ result = compute(prev);
+ if (typeof result === "object" && result !== null)
+ untrack(() => {
+ async = (result as any)[Symbol.asyncIterator] !== undefined || isThenable(result);
+ });
+ // An async-shaped result is registered here, as a projection's body
+ // registers its own flight (`recompute` takes the self-registered
+ // value): a thenable that resolves synchronously, or a stream whose
+ // first yield is buffered, has LANDED when `handleAsync` returns — the
+ // window is closed and no flight opens, so the verdict never flickers
+ // for an answer that was never in flight (A10). Only a result still in
+ // the air opens one. (A thenable that rejects synchronously throws
+ // through here: a real error, or a NotReady the window parks on.)
+ if (async) result = handleAsync(el, result as any);
+ } catch (e) {
+ // The wrapping form (D8): an unready upstream parks the node
+ // (`recompute`'s catch, window open). A real error is the answer (D6).
+ if (e instanceof NotReadyError) openFlight(el, reask);
+ else {
+ el._loading = false;
+ closeFlight(el);
+ }
+ throw e;
+ }
+ if (async && el._loading) {
+ // A pass over a held landing (the node is held: a downstream hold
+ // staged its last landing, and a write to an input re-passed it): the
+ // question is re-asked, the held answer stands. `handleAsync` served
+ // the committed value; returned as the pass's result it would be
+ // compared against the staging (INV-11) and restaged over the landing
+ // — the hold's readers re-deriving against the old input and the
+ // landing lost until the next flight re-lands it. The staging is the
+ // result: unchanged, nothing downstream re-derives, the new flight's
+ // landing replaces it (A34 (1)).
+ if (el._pendingValue !== NOT_PENDING) result = el._pendingValue as T;
+ openFlight(el, reask);
+ } else {
+ el._loading = false;
+ closeFlight(el);
+ }
+ return result;
+ };
+}
+
+/** Installs the landing hook and the seam sweep; called by `createDeferred`. */
+export function installDeferred(): void {
+ if (GlobalQueue._deferredLanded === undefined) {
+ GlobalQueue._deferredLanded = closeFlight;
+ onSeam(seam);
+ }
+}
diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts
index ef6a4b8799..926c471b00 100644
--- a/packages/signals/src/index.ts
+++ b/packages/signals/src/index.ts
@@ -87,6 +87,7 @@ export {
createSignal,
createOptimistic,
createMemo,
+ createDeferred,
createEffect,
createRenderEffect,
createTrackedEffect,
diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts
index 7a7e28e5dc..bed8eb865b 100644
--- a/packages/signals/src/signals.ts
+++ b/packages/signals/src/signals.ts
@@ -6,6 +6,7 @@ import {
TimeoutError,
CONFIG_AUTO_DISPOSE,
CONFIG_CHILDREN_FORBIDDEN,
+ CONFIG_DEFERRED,
CONFIG_FRESH_READ,
CONFIG_GUESS,
createRoot,
@@ -41,6 +42,7 @@ import {
import { REACTIVE_JOINED, STATUS_ERROR, STATUS_PENDING } from "./core/constants.js";
import { unwrapStatusError } from "./core/error.js";
import { optimisticWrite, truthConfirmed, truthRead } from "./core/lanes.js";
+import { deferredCompute, installDeferred, unansweredFlight } from "./deferred.js";
/**
* Low-level reactive-cleanup primitive. Registers a callback that runs when
@@ -490,6 +492,68 @@ export function createMemo(
return accessor(computed(compute as any, options));
}
+/**
+ * Creates an async memo that **may lag the global clock, but never leads it**.
+ *
+ * ```typescript
+ * const value = createDeferred(compute, options?: MemoOptions);
+ * ```
+ *
+ * A plain async memo holds: while a refetch is in flight, everything that
+ * reads it suspends, and the write that caused the refetch is held with it
+ * until the fetch lands — one atomic commit, gated on the slowest fetch in
+ * the tick. `createDeferred` opts one node out of that coordination. Once it
+ * has a committed value, a refetch is invisible to the graph: readers keep
+ * getting the previous answer, the input's write commits immediately, and
+ * the landing commits on its own schedule. The flight is still visible to
+ * `isPending`, which is what drives the panel's "refreshing" indicator.
+ *
+ * - **First load is unchanged.** Before the first answer there is nothing to
+ * serve, so reads suspend to the nearest `` like any async memo.
+ * `loadingValue` composes: a node born committed has no first-load window.
+ * - **It never leads.** Reads never return a value the rest of the graph has
+ * not committed. A flight asked against a write that a sibling is holding
+ * reveals with that write, not ahead of it.
+ * - **Errors propagate.** A rejected refetch throws to the nearest
+ * ``; the stale value never masks it.
+ * - **`refresh(d)` / `until(() => d())`** wait for the landed truth.
+ *
+ * Use it for **independent widgets** where showing the previous answer with
+ * a refreshing indicator is fine (dashboards, feeds, search-as-you-type).
+ * Keep the default `createMemo` for **coherent views** whose parts must agree.
+ *
+ * @param compute a function that receives its previous value and returns a value, Promise, or AsyncIterable
+ * @param options `MemoOptions` -- id, name, equals, unobserved, lazy, transparent, loadingValue
+ *
+ * @example
+ * ```tsx
+ * const rows = createDeferred(() => fetchPanelRows(store.period));
+ *
+ *
+ * ```
+ */
+export function createDeferred(
+ compute: ComputeFunction, T>,
+ options: MemoOptions & { loadingValue: T }
+): SourceAccessor;
+export function createDeferred(
+ compute: ComputeFunction, T>,
+ options?: MemoOptions
+): SourceAccessor;
+export function createDeferred(
+ compute: ComputeFunction, T>,
+ options?: MemoOptions
+): SourceAccessor {
+ installDeferred();
+ const node = computed(deferredCompute(compute as any), options);
+ // The brand core reads (`recompute`'s T4 arm): a held deferred node's
+ // re-pass joins nothing — a lagging question is not an answer.
+ node._config |= CONFIG_DEFERRED;
+ return accessor(node);
+}
+
/**
* Creates a reactive effect with **separate compute and effect phases**.
*
@@ -933,6 +997,10 @@ export function refresh(
node!._statusFlags & (STATUS_PENDING | STATUS_ERROR)
)
throw node!._x?._error;
+ // D9 (deferred.ts): a `createDeferred` re-ask is served the
+ // committed value; the waiter parks on the flight instead, and
+ // the flight's close re-runs it.
+ if (unansweredFlight(node!, null)) throw new NotReadyError(node!);
return v;
},
value => {
@@ -1041,7 +1109,16 @@ export function until(fn: () => T, options?: UntilOptions): Promise
// optimistic guess — a guess cannot satisfy the wait for its own
// confirmation.
watch(
- fn,
+ () => {
+ const value = fn();
+ // D9 (deferred.ts): a `createDeferred` flight the predicate reaches
+ // — directly or through a derivation — is served the committed
+ // value; the predicate parks on the flight instead of confirming
+ // against the previous answer. A staged answer is answered.
+ const flight = unansweredFlight(getObserver() as Computed);
+ if (flight) throw new NotReadyError(flight);
+ return value;
+ },
value => {
// Falsy is "not yet": keep the subscription live and wait for the
// next evaluation. Only a truthy settled value resolves.
diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts
index 175d1686c1..6f8372a00c 100644
--- a/packages/signals/src/store/store.ts
+++ b/packages/signals/src/store/store.ts
@@ -908,6 +908,11 @@ GlobalQueue._slotFlight = (n: Signal): boolean => {
const fam = ((n as any)._host as StoreTarget | undefined)?.fam;
return fam != null && fam.node !== null && (fam.node._statusFlags & STATUS_PENDING) !== 0;
};
+/** Core asks (deferred.ts): the derive a slot node's reads pull — the
+ * `until` predicate's authoritative walk hops it to find a `createDeferred`
+ * flight the derive reads. */
+GlobalQueue._slotDerive = (n: Signal): Computed | null =>
+ ((n as any)._host as StoreTarget | undefined)?.fam?.node ?? null;
/** Core asks (lanes.ts `covered`): the value a slot node's guess covered.
* A chained link's committed value is the inner store's at the last
* read-through — refreshed here, since a guess served the reads since
diff --git a/packages/signals/tests/createDeferred.test.ts b/packages/signals/tests/createDeferred.test.ts
new file mode 100644
index 0000000000..7870de1108
--- /dev/null
+++ b/packages/signals/tests/createDeferred.test.ts
@@ -0,0 +1,1434 @@
+/**
+ * createDeferred — an async memo that may lag the global clock, but never
+ * leads it (docs/create-deferred.md, propositions D1–D11).
+ *
+ * Once initialized, the node's own non-finality is invisible to the graph:
+ * readers are served the committed value during a refetch (no NotReadyError,
+ * no STATUS_PENDING, no frame observing a flight — the flush does not park),
+ * while `isPending` stays loud through the `affects()` mark channel (A24).
+ * The landing commits on the plain path — ambient when the flight was asked
+ * against committed inputs, joining the input's hold when it was asked
+ * against a staged write that a sibling's flight parked (never leads: the
+ * node is held with the transaction like any pending node of the flush).
+ *
+ * The first `describe`s are the PR's (#3710), one per proposition; the "L2
+ * pins" at the end fix what the hold model adds: the boundary interplay, the
+ * flight inside an action, the verdict's display-ahead under a parked flush,
+ * the D9 push through unchanged derivations and derived stores, the
+ * sync-resolved thenable, disposal, `latest`, and mizulu's #3710 shape (a
+ * plain async memo over a deferred node: the common part, the D11 ruling on
+ * the playground's real graph, and its limit — a derivation between the
+ * held memo and the frame joins by ruling 3, L2-general).
+ */
+import {
+ action,
+ createDeferred,
+ createEffect,
+ createErrorBoundary,
+ createLoadingBoundary,
+ createMemo,
+ createOptimistic,
+ createRenderEffect,
+ createRoot,
+ createSignal,
+ createStore,
+ flush,
+ isPending,
+ latest,
+ refresh,
+ until,
+ type SourceAccessor
+} from "../src/index.js";
+
+afterEach(() => flush());
+
+const tick = () => new Promise(r => setTimeout(r, 0));
+async function settle(rounds = 4) {
+ for (let i = 0; i < rounds; i++) {
+ await tick();
+ flush();
+ }
+}
+
+/** A fetcher whose flights resolve only when told to, keyed by argument. */
+function deferredFetcher(compute: (arg: A) => T) {
+ const pending = new Map void; reject: (e: unknown) => void }>();
+ return {
+ fetch(arg: A): Promise {
+ return new Promise((res, rej) => {
+ pending.set(arg, { resolve: () => res(compute(arg)), reject: rej });
+ });
+ },
+ resolve(arg: A) {
+ pending.get(arg)!.resolve();
+ pending.delete(arg);
+ },
+ reject(arg: A, e: unknown) {
+ pending.get(arg)!.reject(e);
+ pending.delete(arg);
+ },
+ inFlight() {
+ return [...pending.keys()];
+ }
+ };
+}
+
+describe("createDeferred", () => {
+ describe("D5 — first load is loading, not pending", () => {
+ it("suspends readers to the nearest Loading boundary until the first landing", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ let slot: string | undefined;
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(period()));
+ const view = createLoadingBoundary(
+ () => rows(),
+ () => "loading"
+ );
+ createRenderEffect(view, v => {
+ slot = v;
+ });
+ });
+ flush();
+ expect(slot).toBe("loading");
+ // Uninitialized: loading-class, not pending.
+ fetcher.resolve(1);
+ await settle();
+ expect(slot).toBe("rows-1");
+ void setPeriod;
+ });
+ });
+
+ describe("D1/D4 — an initialized node serves committed truth during a refetch and reports pending", () => {
+ it("readers keep the committed value, the render effect does not re-run, isPending is loud, the landing commits", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ const log: string[] = [];
+ const pendingLog: boolean[] = [];
+ let rows!: () => string;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(period()));
+ createRenderEffect(rows, v => {
+ log.push(v);
+ });
+ createRenderEffect(
+ () => isPending(rows),
+ v => {
+ pendingLog.push(v);
+ }
+ );
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(log).toEqual(["rows-1"]);
+ expect(pendingLog).toEqual([false]);
+
+ setPeriod(2);
+ flush();
+ // No suspension, no hold: the committed value keeps serving...
+ expect(rows()).toBe("rows-1");
+ expect(log).toEqual(["rows-1"]);
+ // ...and the verdict is loud.
+ expect(isPending(rows)).toBe(true);
+ expect(pendingLog).toEqual([false, true]);
+ // The write to the input committed immediately — nobody held it.
+ expect(period()).toBe(2);
+ expect(isPending(period)).toBe(false);
+
+ fetcher.resolve(2);
+ await settle();
+ expect(rows()).toBe("rows-2");
+ expect(log).toEqual(["rows-1", "rows-2"]);
+ expect(isPending(rows)).toBe(false);
+ expect(pendingLog).toEqual([false, true, false]);
+ });
+
+ it("the originating report: independent panels land on their own schedule", async () => {
+ const fast = deferredFetcher((n: number) => `fast-${n}`);
+ const slow = deferredFetcher((n: number) => `slow-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ let fastSlot: string | undefined, slowSlot: string | undefined, label: number | undefined;
+ createRoot(() => {
+ const fastRows = createDeferred(() => fast.fetch(period()));
+ const slowRows = createDeferred(() => slow.fetch(period()));
+ createRenderEffect(period, v => {
+ label = v;
+ });
+ createRenderEffect(fastRows, v => {
+ fastSlot = v;
+ });
+ createRenderEffect(slowRows, v => {
+ slowSlot = v;
+ });
+ });
+ flush();
+ fast.resolve(1);
+ slow.resolve(1);
+ await settle();
+ expect([label, fastSlot, slowSlot]).toEqual([1, "fast-1", "slow-1"]);
+
+ setPeriod(2);
+ flush();
+ // The selector flips at once; both panels show their previous answer.
+ expect([label, fastSlot, slowSlot]).toEqual([2, "fast-1", "slow-1"]);
+
+ fast.resolve(2);
+ await settle();
+ // The fast panel is not hostage to the slow one.
+ expect([label, fastSlot, slowSlot]).toEqual([2, "fast-2", "slow-1"]);
+
+ slow.resolve(2);
+ await settle();
+ expect([label, fastSlot, slowSlot]).toEqual([2, "fast-2", "slow-2"]);
+ });
+ });
+
+ describe("D3 — a landing during an unrelated incomplete transition commits", () => {
+ it("the deferred consumer repaints while the theme transition is still held", async () => {
+ const images = deferredFetcher((t: string) => `img-${t}`);
+ const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [theme, setTheme] = createSignal("light");
+ const [period, setPeriod] = createSignal(1);
+ let themeSlot: string | undefined, imgSlot: string | undefined, rowsSlot: string | undefined;
+ createRoot(() => {
+ // A plain async memo derived from theme: its render effect reports,
+ // so a theme change opens a transition that waits for the image.
+ const themedImage = createMemo(() => images.fetch(theme()));
+ const rows = createDeferred(() => rowsFetcher.fetch(period()));
+ createRenderEffect(theme, v => {
+ themeSlot = v;
+ });
+ createRenderEffect(themedImage, v => {
+ imgSlot = v;
+ });
+ createRenderEffect(rows, v => {
+ rowsSlot = v;
+ });
+ });
+ flush();
+ images.resolve("light");
+ rowsFetcher.resolve(1);
+ await settle();
+ expect([themeSlot, imgSlot, rowsSlot]).toEqual(["light", "img-light", "rows-1"]);
+
+ // Start the deferred refetch first, against committed inputs.
+ setPeriod(2);
+ flush();
+ expect(rowsSlot).toBe("rows-1");
+
+ // Now the theme switch: held on the image.
+ setTheme("dark");
+ flush();
+ expect([themeSlot, imgSlot]).toEqual(["light", "img-light"]);
+ expect(isPending(theme)).toBe(true);
+
+ // The panel's landing arrives while the theme is held: it commits.
+ rowsFetcher.resolve(2);
+ await settle();
+ expect(rowsSlot).toBe("rows-2");
+ expect([themeSlot, imgSlot]).toEqual(["light", "img-light"]);
+ expect(isPending(theme)).toBe(true);
+
+ images.resolve("dark");
+ await settle();
+ expect([themeSlot, imgSlot, rowsSlot]).toEqual(["dark", "img-dark", "rows-2"]);
+ });
+ });
+
+ describe("D2 — never leads", () => {
+ it("a flight asked against a write a sibling holds reveals with that write, never ahead of it", async () => {
+ const summaryFetcher = deferredFetcher((n: number) => `summary-${n}`);
+ const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ let label: number | undefined, summarySlot: string | undefined, rowsSlot: string | undefined;
+ createRoot(() => {
+ // The plain sibling holds the period write; the deferred panel does not.
+ const summary = createMemo(() => summaryFetcher.fetch(period()));
+ const rows = createDeferred(() => rowsFetcher.fetch(period()));
+ createRenderEffect(period, v => {
+ label = v;
+ });
+ createRenderEffect(summary, v => {
+ summarySlot = v;
+ });
+ createRenderEffect(rows, v => {
+ rowsSlot = v;
+ });
+ });
+ flush();
+ summaryFetcher.resolve(1);
+ rowsFetcher.resolve(1);
+ await settle();
+ expect([label, summarySlot, rowsSlot]).toEqual([1, "summary-1", "rows-1"]);
+
+ setPeriod(2);
+ flush();
+ // The sibling holds the write. The deferred panel serves stale and does
+ // not add to what the transition waits for.
+ expect([label, summarySlot, rowsSlot]).toEqual([1, "summary-1", "rows-1"]);
+ expect(isPending(period)).toBe(true);
+
+ // The deferred flight lands FIRST. Its answer is for period 2, which is
+ // still held — showing it beside label 1 would lead the clock.
+ rowsFetcher.resolve(2);
+ await settle();
+ expect([label, summarySlot, rowsSlot]).toEqual([1, "summary-1", "rows-1"]);
+
+ // The sibling lands: everything reveals together.
+ summaryFetcher.resolve(2);
+ await settle();
+ expect([label, summarySlot, rowsSlot]).toEqual([2, "summary-2", "rows-2"]);
+ });
+
+ it("the transition does not wait for the deferred flight: the sibling landing reveals the write, the panel follows", async () => {
+ const summaryFetcher = deferredFetcher((n: number) => `summary-${n}`);
+ const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ let label: number | undefined, summarySlot: string | undefined, rowsSlot: string | undefined;
+ let rows!: () => string;
+ createRoot(() => {
+ const summary = createMemo(() => summaryFetcher.fetch(period()));
+ rows = createDeferred(() => rowsFetcher.fetch(period()));
+ createRenderEffect(period, v => {
+ label = v;
+ });
+ createRenderEffect(summary, v => {
+ summarySlot = v;
+ });
+ createRenderEffect(rows, v => {
+ rowsSlot = v;
+ });
+ });
+ flush();
+ summaryFetcher.resolve(1);
+ rowsFetcher.resolve(1);
+ await settle();
+
+ setPeriod(2);
+ flush();
+ // Sibling lands while the deferred panel is still in flight.
+ summaryFetcher.resolve(2);
+ await settle();
+ expect([label, summarySlot, rowsSlot]).toEqual([2, "summary-2", "rows-1"]);
+ expect(isPending(rows)).toBe(true);
+
+ rowsFetcher.resolve(2);
+ await settle();
+ expect([label, summarySlot, rowsSlot]).toEqual([2, "summary-2", "rows-2"]);
+ expect(isPending(rows)).toBe(false);
+ });
+ });
+
+ describe("create-deferred.md 8.1 — isPending through derivations of a deferred node", () => {
+ it("a sync memo over the deferred node reads pending during the refetch without re-running", async () => {
+ const fetcher = deferredFetcher((n: number) => Array.from({ length: n }, (_, i) => i));
+ const [n, setN] = createSignal(2);
+ let count!: () => number;
+ let runs = 0;
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ count = createMemo(() => (runs++, rows().length));
+ });
+ flush();
+ fetcher.resolve(2);
+ await settle();
+ expect(count()).toBe(2);
+ expect(isPending(count)).toBe(false);
+ const runsAfterLanding = runs;
+
+ setN(3);
+ flush();
+ expect(count()).toBe(2);
+ expect(runs).toBe(runsAfterLanding);
+ expect(isPending(count)).toBe(true);
+
+ fetcher.resolve(3);
+ await settle();
+ expect(count()).toBe(3);
+ expect(isPending(count)).toBe(false);
+ });
+
+ it("a derived store over the deferred node reads pending at its leaves", async () => {
+ const fetcher = deferredFetcher((n: number) => ({
+ items: Array.from({ length: n }, (_, i) => i)
+ }));
+ const [n, setN] = createSignal(2);
+ let store!: { items: number[] };
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ [store] = createStore(() => rows(), { items: [] as number[] });
+ });
+ flush();
+ fetcher.resolve(2);
+ await settle();
+ expect(store.items.length).toBe(2);
+ expect(isPending(() => store.items.length)).toBe(false);
+
+ setN(3);
+ flush();
+ expect(store.items.length).toBe(2);
+ expect(isPending(() => store.items.length)).toBe(true);
+
+ fetcher.resolve(3);
+ await settle();
+ expect(store.items.length).toBe(3);
+ expect(isPending(() => store.items.length)).toBe(false);
+ });
+
+ it("a landing equal to the committed value still clears the verdict downstream", async () => {
+ const fetcher = deferredFetcher((n: number) => "same");
+ const [n, setN] = createSignal(1);
+ let upper!: () => string;
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ upper = createMemo(() => rows().toUpperCase());
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(upper()).toBe("SAME");
+
+ setN(2);
+ flush();
+ expect(isPending(upper)).toBe(true);
+ fetcher.resolve(2);
+ await settle();
+ expect(upper()).toBe("SAME");
+ expect(isPending(upper)).toBe(false);
+ });
+ });
+
+ describe("D9 — authoritative readers bypass the clamp", () => {
+ it("await refresh(d) resolves with the landed value, not the served stale one", async () => {
+ let calls = 0;
+ const fetcher = deferredFetcher((_: number) => `answer-${++calls}`);
+ let rows!: SourceAccessor;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(0));
+ });
+ flush();
+ fetcher.resolve(0);
+ await settle();
+ expect(rows()).toBe("answer-1");
+
+ let resolved: string | undefined;
+ const p = refresh(rows).then(v => (resolved = v));
+ await settle();
+ // The re-ask is in flight: ordinary readers still see the stale answer,
+ // and the waiter has NOT resolved with it.
+ expect(rows()).toBe("answer-1");
+ expect(resolved).toBeUndefined();
+
+ fetcher.resolve(0);
+ await settle();
+ await p;
+ expect(resolved).toBe("answer-2");
+ expect(rows()).toBe("answer-2");
+ });
+
+ it("until(() => d()) evaluates only settled truth", async () => {
+ const fetcher = deferredFetcher((n: number) => n);
+ const [n, setN] = createSignal(1);
+ let rows!: () => number;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(n()));
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+
+ setN(5);
+ flush();
+ let result: number | undefined;
+ const p = until(() => rows() >= 5).then(() => (result = rows()));
+ await settle();
+ // Stale truth (1) must not be evaluated as the answer; the predicate parks.
+ expect(result).toBeUndefined();
+ fetcher.resolve(5);
+ await settle();
+ await p;
+ expect(result).toBe(5);
+ });
+
+ // The flight is asked against the action's own staged write, so it is
+ // stamped with the action's transaction and its landing is held by it
+ // (D2). The transaction waits on the action, the action on until(): the
+ // predicate must accept the staged answer or the three deadlock.
+ for (const form of ["direct", "derived"] as const) {
+ it(`until(() => ${form === "direct" ? "d()" : "derived()"}) yielded from the action whose write asked the flight settles on the held answer`, async () => {
+ const fetcher = deferredFetcher((n: number) => n);
+ const [n, setN] = createSignal(1);
+ let rows!: () => number;
+ let read!: () => number;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(n()));
+ read = form === "direct" ? rows : createMemo(() => rows() * 10);
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ const target = form === "direct" ? 5 : 50;
+
+ let seen: number | undefined;
+ let done = false;
+ const act = action(function* () {
+ setN(5);
+ // Truthy on the stale value too: only parking on the flight makes
+ // the delivered value the landed one.
+ seen = yield until(() => read());
+ });
+ act().then(() => (done = true));
+ await settle();
+ // The predicate parks on the unanswered flight; the stale value is
+ // not delivered as the answer, and the readers are still served it.
+ expect(seen).toBeUndefined();
+ expect(done).toBe(false);
+ expect(read()).toBe(form === "direct" ? 1 : 10);
+
+ fetcher.resolve(5);
+ await settle();
+ expect(seen).toBe(target);
+ expect(done).toBe(true);
+ expect(n()).toBe(5);
+ expect(read()).toBe(target);
+ });
+ }
+
+ it("refresh(d) is a quiet re-ask: isPending stays false while the waiter still parks (A24)", async () => {
+ let calls = 0;
+ const fetcher = deferredFetcher((_: number) => `answer-${++calls}`);
+ let rows!: SourceAccessor;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(0));
+ });
+ flush();
+ fetcher.resolve(0);
+ await settle();
+
+ const p = refresh(rows);
+ await settle();
+ expect(isPending(rows)).toBe(false);
+ fetcher.resolve(0);
+ await settle();
+ expect(await p).toBe("answer-2");
+ });
+ });
+
+ describe("D6 — errors propagate", () => {
+ it("a rejected refetch throws to the error boundary; the stale value does not mask it", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ let slot: string | undefined;
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ const view = createErrorBoundary(
+ () => rows(),
+ e => `error:${(e() as Error).message}`
+ );
+ createRenderEffect(view, v => {
+ slot = v;
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(slot).toBe("rows-1");
+
+ setN(2);
+ flush();
+ expect(slot).toBe("rows-1");
+ fetcher.reject(2, new Error("boom"));
+ await settle();
+ expect(slot).toBe("error:boom");
+ });
+ });
+
+ describe("create-deferred.md 2.1 — loadingValue composition", () => {
+ it("is born committed, verdict-quiet through the first flight, and loud on refetch", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ let rows!: () => string | undefined;
+ const log: (string | undefined)[] = [];
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(n()), { loadingValue: undefined });
+ createRenderEffect(rows, v => {
+ log.push(v);
+ });
+ });
+ flush();
+ expect(log).toEqual([undefined]);
+ expect(isPending(rows)).toBe(false);
+
+ fetcher.resolve(1);
+ await settle();
+ expect(log).toEqual([undefined, "rows-1"]);
+
+ setN(2);
+ flush();
+ expect(rows()).toBe("rows-1");
+ expect(isPending(rows)).toBe(true);
+ fetcher.resolve(2);
+ await settle();
+ expect(log).toEqual([undefined, "rows-1", "rows-2"]);
+ expect(isPending(rows)).toBe(false);
+ });
+ });
+
+ describe("D8 — the wrapping form", () => {
+ it("createDeferred(() => held()) serves stale while the held memo's other consumers suspend", async () => {
+ const fetcher = deferredFetcher((n: number) => `q-${n}`);
+ const [n, setN] = createSignal(1);
+ let heldSlot: string | undefined, laggedSlot: string | undefined;
+ let lagged!: () => string;
+ createRoot(() => {
+ const query = createMemo(() => fetcher.fetch(n()));
+ lagged = createDeferred(() => query());
+ createRenderEffect(query, v => {
+ heldSlot = v;
+ });
+ createRenderEffect(lagged, v => {
+ laggedSlot = v;
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect([heldSlot, laggedSlot]).toEqual(["q-1", "q-1"]);
+
+ setN(2);
+ flush();
+ expect([heldSlot, laggedSlot]).toEqual(["q-1", "q-1"]);
+ expect(lagged()).toBe("q-1");
+ expect(isPending(lagged)).toBe(true);
+ expect(isPending(n)).toBe(true);
+
+ fetcher.resolve(2);
+ await settle();
+ expect([heldSlot, laggedSlot]).toEqual(["q-2", "q-2"]);
+ expect(isPending(lagged)).toBe(false);
+ });
+ });
+
+ describe("effects and disposal", () => {
+ it("a user effect over the deferred node runs once per landing, never for the flight", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ const log: string[] = [];
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ createEffect(rows, v => {
+ log.push(v);
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ setN(2);
+ flush();
+ await settle();
+ expect(log).toEqual(["rows-1"]);
+ fetcher.resolve(2);
+ await settle();
+ expect(log).toEqual(["rows-1", "rows-2"]);
+ });
+
+ it("a superseded flight's landing is dropped; the newest wins", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ let rows!: () => string;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(n()));
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+
+ setN(2);
+ flush();
+ setN(3);
+ flush();
+ expect(isPending(rows)).toBe(true);
+ fetcher.resolve(2);
+ await settle();
+ expect(rows()).toBe("rows-1");
+ expect(isPending(rows)).toBe(true);
+ fetcher.resolve(3);
+ await settle();
+ expect(rows()).toBe("rows-3");
+ expect(isPending(rows)).toBe(false);
+ });
+ });
+
+ describe("create-deferred.md 8.2 — a landing during an in-flight optimistic action", () => {
+ it("a consumer of both keeps the override (A17) and never flashes the committed value; the other consumer repaints now", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ const [$toggled, setToggled] = createOptimistic(false);
+ const rowA: string[] = [];
+ const rowB: string[] = [];
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ createRenderEffect(
+ () => `${rows()}:${$toggled()}`,
+ v => {
+ rowA.push(v);
+ }
+ );
+ createRenderEffect(rows, v => {
+ rowB.push(v);
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(rowA).toEqual(["rows-1:false"]);
+ expect(rowB).toEqual(["rows-1"]);
+
+ // A refetch is asked, then the user acts optimistically while it flies.
+ setN(2);
+ flush();
+ let finish!: () => void;
+ const act = action(function* () {
+ setToggled(true);
+ yield new Promise(r => (finish = r));
+ });
+ act();
+ flush();
+ expect(rowA).toEqual(["rows-1:false", "rows-1:true"]);
+
+ // The landing arrives mid-action: an ambient commit, no lane.
+ fetcher.resolve(2);
+ await settle();
+ // B is independent of the action: it repaints now.
+ expect(rowB).toEqual(["rows-1", "rows-2"]);
+ // A repaints now too, and keeps the override: the landing is a plain
+ // ambient commit, exactly what a signal write mid-action is — the
+ // committed (un-toggled) value never shows while the action runs.
+ expect(rowA).toEqual(["rows-1:false", "rows-1:true", "rows-2:true"]);
+
+ finish();
+ await settle();
+ // The action settles: the override drops over the landed data. (The
+ // settle's run count is the lane engine's, identical for a plain signal
+ // written mid-action; only the values are pinned here.)
+ expect(rowA.slice(3).every(v => v === "rows-2:false")).toBe(true);
+ expect(rowA.length).toBeGreaterThan(3);
+ });
+ });
+
+ // ── L2 pins (the hold model) ────────────────────────────────────────────
+
+ describe("L2 — Loading boundary: owns the first load, never sees a refetch (create-deferred.md 5)", () => {
+ it("content stays through a refetch — the boundary shows no fallback, isPending is the affordance", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ const log: string[] = [];
+ let rows!: () => string;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(period()));
+ const view = createLoadingBoundary(
+ () => rows(),
+ () => "loading"
+ );
+ createRenderEffect(view, v => {
+ log.push(v);
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(log).toEqual(["loading", "rows-1"]);
+
+ setPeriod(2);
+ flush();
+ // No fallback: the node never went pending, so the boundary has
+ // nothing to catch (A33 — a boundary collects pending readers; there
+ // are none). The write committed.
+ expect(log).toEqual(["loading", "rows-1"]);
+ expect(period()).toBe(2);
+ expect(isPending(rows)).toBe(true);
+
+ fetcher.resolve(2);
+ await settle();
+ expect(log).toEqual(["loading", "rows-1", "rows-2"]);
+ expect(isPending(rows)).toBe(false);
+ });
+
+ it("an `on` re-arm is inert for a deferred source: the boundary arms, nothing pends, the content stays", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ const log: string[] = [];
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(period()));
+ const view = createLoadingBoundary(
+ () => rows(),
+ () => "loading",
+ { on: () => period() }
+ );
+ createRenderEffect(view, v => {
+ log.push(v);
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(log).toEqual(["loading", "rows-1"]);
+
+ setPeriod(2);
+ flush();
+ // The re-arm would flip to the fallback at the first pending reader
+ // under it; a deferred refetch pends nobody (create-deferred.md 5: "a deferred node's
+ // refetch never arrives").
+ expect(log).toEqual(["loading", "rows-1"]);
+ fetcher.resolve(2);
+ await settle();
+ expect(log).toEqual(["loading", "rows-1", "rows-2"]);
+ });
+ });
+
+ describe("L2 — inside an action: the flight is the action's work, never ahead of it", () => {
+ it("a flight asked by the action's write is held with the action; its landing reveals at the settle", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ let label: number | undefined, rowsSlot: string | undefined;
+ let rows!: () => string;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(period()));
+ createRenderEffect(period, v => {
+ label = v;
+ });
+ createRenderEffect(rows, v => {
+ rowsSlot = v;
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+
+ let finish!: () => void;
+ const act = action(function* () {
+ setPeriod(2);
+ yield new Promise(r => (finish = r));
+ });
+ act();
+ await settle();
+ // The action holds the write; the panel serves stale and does not
+ // suspend the body (nothing holds the action but itself).
+ expect([label, rowsSlot]).toEqual([1, "rows-1"]);
+ expect(isPending(period)).toBe(true);
+ expect(isPending(rows)).toBe(true);
+
+ // The landing arrives mid-action: asked against the held write, it is
+ // the action's (D2) — staged under its transaction, nothing moves.
+ fetcher.resolve(2);
+ await settle();
+ expect([label, rowsSlot]).toEqual([1, "rows-1"]);
+ // Not final either way: the landed value is held uncommitted (A19 iii).
+ expect(isPending(rows)).toBe(true);
+
+ finish();
+ await settle();
+ expect([label, rowsSlot]).toEqual([2, "rows-2"]);
+ expect(isPending(rows)).toBe(false);
+ expect(isPending(period)).toBe(false);
+ });
+
+ it("the action does not wait for the flight: it settles, the write reveals, the panel follows", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ let label: number | undefined, rowsSlot: string | undefined;
+ let rows!: () => string;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(period()));
+ createRenderEffect(period, v => {
+ label = v;
+ });
+ createRenderEffect(rows, v => {
+ rowsSlot = v;
+ });
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+
+ let finish!: () => void;
+ let done = false;
+ const act = action(function* () {
+ setPeriod(2);
+ yield new Promise(r => (finish = r));
+ });
+ act().then(() => (done = true));
+ await settle();
+ finish();
+ await settle();
+ // The body returned and nothing on screen derives from a flight the
+ // transaction holds (`blocked`: a deferred node is never pending), so
+ // it lands: the write reveals, the panel still shows its previous answer.
+ expect(done).toBe(true);
+ expect([label, rowsSlot]).toEqual([2, "rows-1"]);
+ expect(isPending(rows)).toBe(true);
+
+ fetcher.resolve(2);
+ await settle();
+ expect([label, rowsSlot]).toEqual([2, "rows-2"]);
+ expect(isPending(rows)).toBe(false);
+ });
+ });
+
+ describe("L2 — the verdict is display-ahead (ruling 6): a tracked isPending flips while the flush parks", () => {
+ it("a render effect on isPending(rows) shows true at once, though a sibling's flight holds the write", async () => {
+ const summaryFetcher = deferredFetcher((n: number) => `summary-${n}`);
+ const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [period, setPeriod] = createSignal(1);
+ const pendingLog: boolean[] = [];
+ let summarySlot: string | undefined;
+ createRoot(() => {
+ const summary = createMemo(() => summaryFetcher.fetch(period()));
+ const rows = createDeferred(() => rowsFetcher.fetch(period()));
+ createRenderEffect(summary, v => {
+ summarySlot = v;
+ });
+ createRenderEffect(
+ () => isPending(rows),
+ v => {
+ pendingLog.push(v);
+ }
+ );
+ });
+ flush();
+ summaryFetcher.resolve(1);
+ rowsFetcher.resolve(1);
+ await settle();
+ expect(pendingLog).toEqual([false]);
+
+ setPeriod(2);
+ flush();
+ // The flush parked on `summary`; the verdict reader is the holder's
+ // verdict lane's — shown now, not stashed with the frame.
+ expect(summarySlot).toBe("summary-1");
+ expect(pendingLog).toEqual([false, true]);
+
+ // The panel's landing joins the hold (D2); the verdict stays true
+ // through it — the landed value is held uncommitted (A19 iii), and the
+ // reader re-derives at the held landing exactly as a plain memo's
+ // does (a render effect has no equality gate; the run count is the
+ // engine's, the values are pinned here)...
+ rowsFetcher.resolve(2);
+ await settle();
+ expect(pendingLog.every(v => v)).toBe(false);
+ expect(pendingLog.slice(1).every(v => v)).toBe(true);
+ // ...and flips at the reveal.
+ summaryFetcher.resolve(2);
+ await settle();
+ expect(summarySlot).toBe("summary-2");
+ expect(pendingLog.at(-1)).toBe(false);
+ expect(pendingLog.filter(v => !v)).toEqual([false, false]);
+ });
+ });
+
+ describe("L2 — D9's push: the flight's close wakes the readers parked on it", () => {
+ it("until(() => derived()) over an unchanged derivation is released by an equal-value landing", async () => {
+ // The derivation never re-runs (the landing equals the committed
+ // value), so no value notification reaches the predicate; the flight's
+ // close re-runs it (deferred.ts `wake`).
+ const fetcher = deferredFetcher((_: number) => 1);
+ const [n, setN] = createSignal(1);
+ let derived!: () => number;
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ derived = createMemo(() => rows() * 10);
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ expect(derived()).toBe(10);
+
+ setN(2);
+ flush();
+ let result: number | undefined;
+ const p = until(() => derived()).then(v => (result = v));
+ await settle();
+ expect(result).toBeUndefined();
+ fetcher.resolve(2);
+ await settle();
+ await p;
+ expect(result).toBe(10);
+ });
+
+ it("until over a derived store built on the deferred node parks on the flight through the family's derive", async () => {
+ const fetcher = deferredFetcher((n: number) => ({
+ items: Array.from({ length: n }, (_, i) => i)
+ }));
+ const [n, setN] = createSignal(2);
+ let store!: { items: number[] };
+ createRoot(() => {
+ const rows = createDeferred(() => fetcher.fetch(n()));
+ [store] = createStore(() => rows(), { items: [] as number[] });
+ });
+ flush();
+ fetcher.resolve(2);
+ await settle();
+ expect(store.items.length).toBe(2);
+
+ setN(3);
+ flush();
+ let result: number | undefined;
+ // Truthy on the stale store too: only parking on the flight (reached
+ // from the leaf through its family's derive) delivers the landed one.
+ const p = until(() => store.items.length).then(v => (result = v));
+ await settle();
+ expect(result).toBeUndefined();
+ fetcher.resolve(3);
+ await settle();
+ await p;
+ expect(result).toBe(3);
+ });
+ });
+
+ describe("L2 — a refetch that resolves synchronously is a landing (create-deferred.md 8.3, closed)", () => {
+ it("a sync-resolving thenable closes the flight in the same flush; derived verdict readers re-derive", async () => {
+ const [n, setN] = createSignal(1);
+ let sync = false;
+ const thenable = (v: string) => ({
+ then(res: (v: string) => void) {
+ if (sync) res(v);
+ else setTimeout(() => res(v), 0);
+ }
+ });
+ let upper!: () => string;
+ const pendingLog: boolean[] = [];
+ createRoot(() => {
+ const rows = createDeferred(() => thenable(`rows-${n()}`));
+ upper = createMemo(() => rows().toUpperCase());
+ createRenderEffect(
+ () => isPending(upper),
+ v => {
+ pendingLog.push(v);
+ }
+ );
+ });
+ flush();
+ await settle();
+ expect(upper()).toBe("ROWS-1");
+ expect(pendingLog).toEqual([false]);
+
+ sync = true;
+ setN(2);
+ flush();
+ expect(upper()).toBe("ROWS-2");
+ expect(isPending(upper)).toBe(false);
+ // The answer was never in flight: no reader saw it pending (the probe
+ // re-ran for `upper`'s change, and read final).
+ expect(pendingLog.every(v => v === false)).toBe(true);
+ });
+ });
+
+ describe("L2 — disposal and latest", () => {
+ it("a node disposed mid-flight outside a flush drops its landing and keeps its mark until the next seam that runs", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ const [tick, setTick] = createSignal(0);
+ let rows!: () => string;
+ let dispose!: () => void;
+ createRoot(d => {
+ dispose = d;
+ rows = createDeferred(() => fetcher.fetch(n()));
+ createRenderEffect(rows, () => {});
+ });
+ createRoot(() => {
+ // An unrelated live graph: writing `tick` gives the scheduler real
+ // work, so its flush ends in a seam.
+ createRenderEffect(tick, () => {});
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+ setN(2);
+ flush();
+ expect(isPending(rows)).toBe(true);
+ // Disposing outside a flush schedules nothing (disposeChildren only
+ // forces a seam for a held flight or a stale frame reader), so the open
+ // flight's mark outlives the node; the late landing is still dropped by
+ // identity and a dead node freezes at its last committed value (#3024).
+ dispose();
+ flush();
+ fetcher.resolve(2);
+ await settle();
+ expect(rows()).toBe("rows-1");
+ expect(isPending(rows)).toBe(true);
+ // The next seam that runs — here, an unrelated write's flush — sweeps
+ // the dead flight and releases the mark.
+ setTick(1);
+ flush();
+ expect(rows()).toBe("rows-1");
+ expect(isPending(rows)).toBe(false);
+ });
+
+ it("latest(() => d()) is a no-op — nothing staged to lead with — and its own-async verdict is loud (A8)", async () => {
+ const fetcher = deferredFetcher((n: number) => `rows-${n}`);
+ const [n, setN] = createSignal(1);
+ let rows!: () => string;
+ createRoot(() => {
+ rows = createDeferred(() => fetcher.fetch(n()));
+ });
+ flush();
+ fetcher.resolve(1);
+ await settle();
+
+ setN(2);
+ flush();
+ expect(latest(() => rows())).toBe("rows-1");
+ expect(isPending(() => latest(() => rows()))).toBe(true);
+ fetcher.resolve(2);
+ await settle();
+ expect(latest(() => rows())).toBe("rows-2");
+ expect(isPending(() => latest(() => rows()))).toBe(false);
+ });
+ });
+
+ /** mizulu's report on #3710 (playground `Lr5tYDWOTW2PD3JSHCdwbA`): a
+ * plain async memo `m1` over a deferred `d1` over a signal `count`, both
+ * read by a render effect under a Loading boundary, `count` also read by
+ * a render effect outside it (the button). "Click before m1:exec and the
+ * button goes freely; m1:exec shown but m1:done not yet, and every click
+ * in that period is blocked — the button does not increment until
+ * m1:end." Same on the PR and on L2. */
+ describe("L2 — a plain async memo over a deferred node (mizulu's #3710 report)", () => {
+ function playground(downstream: typeof createMemo) {
+ const d1Fetch = deferredFetcher((c: number) => c * 2);
+ const m1Fetch = deferredFetcher((d: number) => d);
+ const [count, setCount] = createSignal(0);
+ let d1!: () => number;
+ let m1!: () => number;
+ const out = { view: "", button: -1 };
+ createRoot(() => {
+ d1 = createDeferred(() => d1Fetch.fetch(count()));
+ m1 = downstream(() => m1Fetch.fetch(d1()));
+ const boundary = createLoadingBoundary(
+ () => `signal ${count()} deferred ${d1()} m1 ${m1()}`,
+ () => "loading..."
+ );
+ createRenderEffect(boundary, v => {
+ out.view = v;
+ });
+ createRenderEffect(count, v => {
+ out.button = v;
+ });
+ });
+ return { d1Fetch, m1Fetch, setCount, d1: () => d1(), m1: () => m1(), out };
+ }
+ async function load(p: ReturnType) {
+ flush();
+ expect(p.out.view).toBe("loading...");
+ p.d1Fetch.resolve(0);
+ await settle();
+ p.m1Fetch.resolve(0);
+ await settle();
+ expect(p.out.view).toBe("signal 0 deferred 0 m1 0");
+ expect(p.out.button).toBe(0);
+ }
+
+ it("a write to the input never waits on the deferred flight (D1): the button and the boundary's sync read repaint, the deferred lags", async () => {
+ const p = playground(createMemo);
+ await load(p);
+ p.setCount(1);
+ flush();
+ expect(p.out.button).toBe(1);
+ expect(p.out.view).toBe("signal 1 deferred 0 m1 0");
+ expect(isPending(p.d1)).toBe(true);
+ p.setCount(2);
+ flush();
+ expect(p.out.button).toBe(2);
+ expect(p.out.view).toBe("signal 2 deferred 0 m1 0");
+ // A superseded landing is dropped; the live one re-runs the plain memo.
+ p.d1Fetch.resolve(1);
+ await settle();
+ expect(p.out.view).toBe("signal 2 deferred 0 m1 0");
+ p.d1Fetch.resolve(2);
+ await settle();
+ // The landing re-ran `m1`, which went pending under the frame that
+ // reads it: an ordinary async memo's hold (A15), and the landing — an
+ // ordinary commit — is staged with it. Nothing deferred-specific
+ // holds here (D1: `d1` itself blocks no transaction).
+ expect(p.m1Fetch.inFlight()).toEqual([4]);
+ expect(p.out.view).toBe("signal 2 deferred 0 m1 0");
+ expect(isPending(p.m1)).toBe(true);
+ p.m1Fetch.resolve(4);
+ await settle();
+ expect(p.out.view).toBe("signal 2 deferred 4 m1 4");
+ expect(p.out.button).toBe(2);
+ });
+
+ it("with the downstream memo deferred too, no click ever waits: both lag, the sync reads never do (D1, D3)", async () => {
+ const p = playground(createDeferred as typeof createMemo);
+ await load(p);
+ p.setCount(1);
+ flush();
+ p.setCount(2);
+ flush();
+ expect(p.out.button).toBe(2);
+ p.d1Fetch.resolve(1);
+ p.d1Fetch.resolve(2);
+ await settle();
+ // The landing commits (D3) and `m1` re-asks without holding.
+ expect(p.out.view).toBe("signal 2 deferred 4 m1 0");
+ expect(p.m1Fetch.inFlight()).toEqual([4]);
+ p.setCount(3);
+ flush();
+ expect(p.out.button).toBe(3);
+ expect(p.out.view).toBe("signal 3 deferred 4 m1 0");
+ p.setCount(4);
+ flush();
+ expect(p.out.button).toBe(4);
+ expect(p.out.view).toBe("signal 4 deferred 4 m1 0");
+ p.m1Fetch.resolve(4);
+ await settle();
+ expect(p.out.view).toBe("signal 4 deferred 4 m1 4");
+ for (const k of p.d1Fetch.inFlight()) p.d1Fetch.resolve(k);
+ await settle();
+ expect(p.out.view).toBe("signal 4 deferred 8 m1 4");
+ for (const k of p.m1Fetch.inFlight()) p.m1Fetch.resolve(k);
+ await settle();
+ expect(p.out.view).toBe("signal 4 deferred 8 m1 8");
+ expect(isPending(p.d1)).toBe(false);
+ expect(isPending(p.m1)).toBe(false);
+ });
+
+ // The common part (lands ahead of the ruling, correct under either
+ // reading): a re-pass over a held landing keeps it. `m1`'s flight holds
+ // the frame and `d1`'s landing (2) is staged with it; a mainline write
+ // to `count` re-passes `d1`. The wrapper's pass returns the staging as
+ // its result — `handleAsync` served the committed value, and returned
+ // as the result it was compared against the staging (INV-11) and
+ // restaged over the landing: `m1` re-fetched against the OLD input and
+ // the landing was lost until a later flight re-landed it. And the seam
+ // sweep re-opens the window a commit closed under a question it did
+ // not answer: when the hold lands, `commitPendingNode` closes `d1`'s
+ // window on the older landing while the click's flight is still in the
+ // air (D4). Pinned on the text-memo graph, where the tick is held
+ // either way (the memo reads `m1`, ruling 3).
+ describe("a re-pass over a held landing keeps it (the common part)", () => {
+ it("m1 keeps its one flight; the hold lands with the landing it staged; isPending(d1) stays true while the newer flight is live", async () => {
+ const p = playground(createMemo);
+ await load(p);
+ p.setCount(1);
+ flush();
+ p.d1Fetch.resolve(1);
+ await settle();
+ expect(p.out.view).toBe("signal 1 deferred 0 m1 0");
+ expect(p.m1Fetch.inFlight()).toEqual([2]);
+ // Clicks while m1 is in flight re-pass d1 (held, staged 2).
+ p.setCount(2);
+ flush();
+ p.setCount(3);
+ flush();
+ // The held landing stands: m1 is not re-run against the old input;
+ // d1's own questions are the clicks'.
+ expect(p.m1Fetch.inFlight()).toEqual([2]);
+ expect(p.d1Fetch.inFlight()).toEqual([2, 3]);
+ expect(isPending(p.d1)).toBe(true);
+ p.m1Fetch.resolve(2);
+ await settle();
+ // The hold lands with the landing it staged, never without it.
+ expect(p.out.view).toBe("signal 3 deferred 2 m1 2");
+ expect(p.out.button).toBe(3);
+ // The commit of the older landing did not answer the newer question
+ // — the window re-opens at the seam, the mark stays (D4).
+ expect(isPending(p.d1)).toBe(true);
+ expect(isPending(p.m1)).toBe(true);
+ p.d1Fetch.resolve(2); // superseded: dropped by identity
+ await settle();
+ expect(p.out.view).toBe("signal 3 deferred 2 m1 2");
+ p.d1Fetch.resolve(3);
+ await settle();
+ expect(p.m1Fetch.inFlight()).toEqual([6]);
+ p.m1Fetch.resolve(6);
+ await settle();
+ expect(p.out.view).toBe("signal 3 deferred 6 m1 6");
+ expect(isPending(p.d1)).toBe(false);
+ expect(isPending(p.m1)).toBe(false);
+ });
+ });
+
+ /** The playground's real graph: the `{}` inserts under `` are
+ * render effects of their own; the boundary's compute reads none of the
+ * held nodes. (The text-memo `playground()` above puts a derivation
+ * between them and the frame — which joins any hold it reads, ruling 3.) */
+ function playgroundDom() {
+ const d1Fetch = deferredFetcher((c: number) => c * 2);
+ const m1Fetch = deferredFetcher((d: number) => d);
+ const [count, setCount] = createSignal(0);
+ let d1!: () => number;
+ let m1!: () => number;
+ const dom = { fallback: false, sig: -1, def: -1, m1: -1, button: -1 };
+ createRoot(() => {
+ d1 = createDeferred(() => d1Fetch.fetch(count()));
+ m1 = createMemo(() => m1Fetch.fetch(d1()));
+ const boundary = createLoadingBoundary(
+ () => {
+ createRenderEffect(count, v => {
+ dom.sig = v;
+ });
+ createRenderEffect(d1, v => {
+ dom.def = v;
+ });
+ createRenderEffect(m1, v => {
+ dom.m1 = v;
+ });
+ return "content";
+ },
+ () => "fallback"
+ );
+ createRenderEffect(boundary, v => {
+ dom.fallback = v === "fallback";
+ });
+ createRenderEffect(count, v => {
+ dom.button = v;
+ });
+ });
+ const view = () =>
+ dom.fallback ? "fallback" : `signal ${dom.sig} deferred ${dom.def} m1 ${dom.m1}`;
+ return { d1Fetch, m1Fetch, setCount, d1: () => d1(), m1: () => m1(), dom, view };
+ }
+
+ // Ruled 2026-10-04 (D11 — a lagging question is not an answer). Once `m1`'s
+ // flight holds the frame, `d1` is a held node (its landing is staged in
+ // that hold). A mainline write to `count` re-passes it; the pass joins
+ // nothing (`recompute`'s T4 arm skips a CONFIG_DEFERRED node) and keeps
+ // the held landing (the wrapper returns the staging as its result), so
+ // the write commits mainline: the button and the boundary's sync insert
+ // repaint, the inserts of `d1` and `m1` are stale readers re-derived at
+ // `m1`'s landing (A15), and the seam sweep keeps `d1`'s window open
+ // under the question the commit of the older landing did not answer
+ // (D4).
+ describe("a write to the input while the downstream plain memo is in flight commits mainline; the held landing stands (D11)", () => {
+ it("the button never waits; the frame's readers of the flight are stale readers; m1 lands with the input it was asked with", async () => {
+ const p = playgroundDom();
+ flush();
+ expect(p.view()).toBe("fallback");
+ p.d1Fetch.resolve(0);
+ await settle();
+ p.m1Fetch.resolve(0);
+ await settle();
+ expect(p.view()).toBe("signal 0 deferred 0 m1 0");
+ p.setCount(1);
+ flush();
+ expect(p.view()).toBe("signal 1 deferred 0 m1 0");
+ expect(p.dom.button).toBe(1);
+ p.d1Fetch.resolve(1);
+ await settle();
+ // m1 re-ran on the landing and holds the frame; the landing is staged with it.
+ expect(p.view()).toBe("signal 1 deferred 0 m1 0");
+ expect(p.m1Fetch.inFlight()).toEqual([2]);
+ expect(isPending(p.m1)).toBe(true);
+ // Clicks while m1 is in flight: mainline. The sync readers repaint;
+ // the readers of the flight keep the committed frame.
+ p.setCount(2);
+ flush();
+ expect(p.dom.button).toBe(2);
+ expect(p.view()).toBe("signal 2 deferred 0 m1 0");
+ p.setCount(3);
+ flush();
+ expect(p.dom.button).toBe(3);
+ expect(p.view()).toBe("signal 3 deferred 0 m1 0");
+ // The held landing (2) stands: m1 keeps its one flight; d1's own
+ // questions are the clicks'.
+ expect(p.m1Fetch.inFlight()).toEqual([2]);
+ expect(p.d1Fetch.inFlight()).toEqual([2, 3]);
+ expect(isPending(p.d1)).toBe(true);
+ p.m1Fetch.resolve(2);
+ await settle();
+ // The hold lands with the landing it staged; the stale readers
+ // re-derive on it.
+ expect(p.view()).toBe("signal 3 deferred 2 m1 2");
+ expect(p.dom.button).toBe(3);
+ // d1's newest question is still in the air (D4).
+ expect(isPending(p.d1)).toBe(true);
+ p.d1Fetch.resolve(2);
+ await settle();
+ expect(p.view()).toBe("signal 3 deferred 2 m1 2");
+ p.d1Fetch.resolve(3);
+ await settle();
+ expect(p.m1Fetch.inFlight()).toEqual([6]);
+ p.m1Fetch.resolve(6);
+ await settle();
+ expect(p.view()).toBe("signal 3 deferred 6 m1 6");
+ expect(isPending(p.d1)).toBe(false);
+ expect(isPending(p.m1)).toBe(false);
+ });
+
+ // The limit of D11, L2-general and not deferred-specific: a derivation
+ // (here the boundary's text memo) re-run by the write reads the held
+ // pending `m1` and joins its hold (`read` → `joinPass`, ruling 3: a
+ // derivation of a held node published mainline would tear). The
+ // deferred node is not involved — the pure-L2 control below, with no
+ // deferred node in the graph, holds the same way.
+ it("with a derivation between the held memo and the frame, the write still joins — the derivation reads m1 (ruling 3); nothing deferred-specific", async () => {
+ const p = playground(createMemo);
+ await load(p);
+ p.setCount(1);
+ flush();
+ p.d1Fetch.resolve(1);
+ await settle();
+ expect(p.m1Fetch.inFlight()).toEqual([2]);
+ p.setCount(2);
+ flush();
+ // The boundary's text memo re-passes, reads the held pending `m1`,
+ // and joins its hold: the tick parks, the button with it — as it
+ // would with no deferred node in the graph at all.
+ expect(p.out.button).toBe(1);
+ expect(p.out.view).toBe("signal 1 deferred 0 m1 0");
+ // The held landing stands all the same (the common part).
+ expect(p.m1Fetch.inFlight()).toEqual([2]);
+ p.m1Fetch.resolve(2);
+ await settle();
+ expect(p.out.view).toBe("signal 2 deferred 2 m1 2");
+ expect(p.out.button).toBe(2);
+ p.d1Fetch.resolve(2);
+ await settle();
+ p.m1Fetch.resolve(4);
+ await settle();
+ expect(p.out.view).toBe("signal 2 deferred 4 m1 4");
+ });
+
+ it("pure-L2 control: no deferred node — a derivation reading a held pending memo joins the hold on an unrelated write", async () => {
+ const fetcher = deferredFetcher((n: number) => n * 10);
+ const [a, setA] = createSignal(0);
+ const [b, setB] = createSignal(0);
+ const out = { view: "", button: -1 };
+ let m!: () => number;
+ createRoot(() => {
+ m = createMemo(() => fetcher.fetch(a()));
+ const boundary = createLoadingBoundary(
+ () => `b ${b()} m ${m()}`,
+ () => "loading..."
+ );
+ createRenderEffect(boundary, v => {
+ out.view = v;
+ });
+ createRenderEffect(b, v => {
+ out.button = v;
+ });
+ });
+ flush();
+ fetcher.resolve(0);
+ await settle();
+ expect(out.view).toBe("b 0 m 0");
+ setA(1);
+ flush();
+ expect(isPending(m)).toBe(true);
+ // A write to `b`, unrelated to the flight: the text memo re-passes,
+ // reads the held pending `m`, joins — the button waits too.
+ setB(1);
+ flush();
+ expect(out.button).toBe(0);
+ expect(out.view).toBe("b 0 m 0");
+ fetcher.resolve(1);
+ await settle();
+ expect(out.view).toBe("b 1 m 10");
+ expect(out.button).toBe(1);
+ });
+ });
+ });
+});
diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts
index 26af62029f..0eac9aac19 100644
--- a/packages/signals/tests/treeshake.test.ts
+++ b/packages/signals/tests/treeshake.test.ts
@@ -80,6 +80,7 @@ describe("pay-for-use tree-shaking (#2883)", () => {
"boundaries.ts",
"map.ts",
"affects.ts",
+ "deferred.ts",
"core/verdict.ts",
"core/lanes.ts",
"core/action.ts",
@@ -508,6 +509,42 @@ describe("pay-for-use tree-shaking (#2883)", () => {
expect(retainedFrom(retained, ["core/lanes.ts"])).toEqual(["core/lanes.ts"]);
});
+ it("createDeferred loads the deferred module and the mark channel, and nothing else new", async () => {
+ const { retained } = await bundleFixture(
+ `export { createSignal, createEffect, createRoot, flush, createDeferred } from "sigsrc";`
+ );
+ // The clamp is deferred.ts's; its verdict is a mark on the node
+ // (affects.ts — the `affects()` channel, A24), so the mark module comes
+ // with it. The verdict layer that READS the mark does not: a program
+ // that never asks `isPending` pays nothing for the answer.
+ expect(retainedFrom(retained, ["deferred.ts", "affects.ts"])).toEqual([
+ "deferred.ts",
+ "affects.ts"
+ ]);
+ expect(
+ retainedFrom(retained, [
+ "store/store.ts",
+ "store/projection.ts",
+ "boundaries.ts",
+ "map.ts",
+ "core/verdict.ts",
+ "core/lanes.ts",
+ "core/action.ts"
+ ])
+ ).toEqual([]);
+ });
+
+ it("refresh/until carry only the deferred module's authoritative walk, not its clamp or the mark channel", async () => {
+ const { retained } = await bundleFixture(
+ `export { createSignal, createMemo, createEffect, createRoot, flush, refresh, until } from "sigsrc";`
+ );
+ // D9's check (`unansweredFlight`) lives in deferred.ts and is one
+ // `Set.size` compare when nothing is deferred; the wrapper, the mark
+ // registration and affects.ts shed with `createDeferred`.
+ expect(retainedFrom(retained, ["deferred.ts"])).toEqual(["deferred.ts"]);
+ expect(retainedFrom(retained, ["affects.ts"])).toEqual([]);
+ });
+
it("isPending/latest load the verdict layer and nothing else new", async () => {
const { retained } = await bundleFixture(
`export { createSignal, createEffect, createRoot, flush, isPending, latest } from "sigsrc";`
diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts
index b2db5b8b85..734e2c5388 100644
--- a/packages/solid/src/client/hydration.ts
+++ b/packages/solid/src/client/hydration.ts
@@ -11,6 +11,7 @@ import {
peekNextChildId,
createRevealOrder as coreRevealOrder,
createMemo as coreMemo,
+ createDeferred as coreDeferred,
createSignal as coreSignal,
createOptimistic as coreOptimistic,
createProjection as coreProjection,
@@ -1621,6 +1622,66 @@ export const createMemo: {
return (_createMemo || coreMemo)(...args);
}) as any;
+/**
+ * Creates an async memo that **may lag the global clock, but never leads it**.
+ *
+ * ```ts
+ * const value = createDeferred(compute, options?: MemoOptions);
+ * ```
+ *
+ * A plain async `createMemo` holds: while a refetch is in flight, its readers
+ * suspend and the write that caused the refetch is held with it until the
+ * fetch lands — one atomic commit. `createDeferred` opts one node out of that
+ * coordination. Once it has a committed value, a refetch is invisible to the
+ * graph: readers keep the previous answer, the input's write commits
+ * immediately, and the landing commits on its own schedule. The flight stays
+ * visible to `isPending`, which drives the "refreshing" indicator.
+ *
+ * - **First load is unchanged** — reads suspend to the nearest ``
+ * until the first answer. `loadingValue` composes: a node born committed
+ * has no first-load window.
+ * - **It never leads** — a flight asked against a write another node is
+ * holding reveals with that write, not ahead of it.
+ * - **Errors propagate** — a rejected refetch throws to the nearest
+ * ``; the stale value never masks it.
+ *
+ * Use it for independent widgets where the previous answer plus a refreshing
+ * indicator is fine (dashboards, feeds, search-as-you-type); keep `createMemo`
+ * for coherent views whose parts must agree.
+ *
+ * **Hydration:** as `createMemo` — `ssrSource`, `transparent`, and the
+ * serialized-value handoff behave identically; the server renders it as a
+ * plain memo.
+ *
+ * @example
+ * ```tsx
+ * const rows = createDeferred(() => fetchPanelRows(period()));
+ *
+ *
+ * ```
+ */
+export const createDeferred: {
+ (
+ compute: ComputeFunction, T>,
+ options: HydrationMemoOptions & { loadingValue: T }
+ ): SourceAccessor;
+ (
+ compute: ComputeFunction, T>,
+ options?: HydrationMemoOptions
+ ): SourceAccessor;
+} = ((compute: any, options?: any) => {
+ // The server renders a deferred node as a plain memo (its clamp is a client
+ // scheduling property), so it hydrates exactly like one: the serialized
+ // value is commit #0, and the first refetch after that is already clamped.
+ // Through the adapter slot, as createOptimistic: an enableHydration()-
+ // installed impl would retain the primitive in every hydrating bundle.
+ return sharedConfig.hydrating
+ ? _hydrateSignalLike!(coreDeferred, compute, options)
+ : coreDeferred(compute, options);
+}) as any;
+
/**
* Creates a simple reactive state with a getter and setter.
*
diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts
index c07307961f..932ba88520 100644
--- a/packages/solid/src/index.ts
+++ b/packages/solid/src/index.ts
@@ -102,6 +102,7 @@ export {
enableHydration,
createRoot,
createMemo,
+ createDeferred,
createSignal,
createStore,
createProjection,
@@ -303,7 +304,7 @@ export {
batch, // flush
catchError, // old version handled by createErrorBoundary. new version is different helper.
createComputed, // nope
- createDeferred, // take it outside
+ createDeferred, // 1.x's idle-callback memo is gone; the 2.0 export of this name is the async memo that may lag but never leads
createResource, // all computations
createSelector, // createProjection
DevHooks,
diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts
index d24f6cb026..e1dde9a7b3 100644
--- a/packages/solid/src/server/index.ts
+++ b/packages/solid/src/server/index.ts
@@ -10,6 +10,7 @@ export {
affects,
createEffect,
createMemo,
+ createDeferred,
createOptimistic,
createOptimisticStore,
createOwner,
diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts
index 3f01bfa116..c780a49d97 100644
--- a/packages/solid/src/server/signals.ts
+++ b/packages/solid/src/server/signals.ts
@@ -1425,6 +1425,26 @@ export function createMemo(
return read;
}
+// Server identity (docs/create-deferred.md D7): the clamp is a client
+// scheduling property — a refetch served the previous answer. A server render
+// has one pass and no previous answer, so a deferred node renders exactly as
+// the memo it wraps: its first load suspends to the nearest boundary, its
+// value serializes for the client to adopt as commit #0.
+export function createDeferred(
+ compute: ComputeFunction, T>,
+ options: ServerMemoOptions & { loadingValue: T }
+): SourceAccessor;
+export function createDeferred(
+ compute: ComputeFunction, T>,
+ options?: ServerMemoOptions
+): SourceAccessor;
+export function createDeferred(
+ compute: ComputeFunction, T>,
+ options?: ServerMemoOptions
+): SourceAccessor {
+ return createMemo(compute, options as any);
+}
+
/**
* Lean SSR memo for computes statically guaranteed to return synchronously
* (no Promise / AsyncIterable result).
diff --git a/packages/web/test/create-deferred-mizulu-3710.spec.tsx b/packages/web/test/create-deferred-mizulu-3710.spec.tsx
new file mode 100644
index 0000000000..dbb144ebbc
--- /dev/null
+++ b/packages/web/test/create-deferred-mizulu-3710.spec.tsx
@@ -0,0 +1,112 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+import { expect, test } from "vitest";
+import { createDeferred, createMemo, createSignal, flush, Loading } from "solid-js";
+import { render } from "@solidjs/web";
+
+/**
+ * mizulu's report on #3710 — exact port of the playground
+ * (https://s.olid.uk/id/Lr5tYDWOTW2PD3JSHCdwbA), timings scaled 3000 -> 60.
+ * A plain async memo `m1` over a deferred `d1` over a signal `count`, under
+ * ``; the button outside reads `count`. "Click before m1:exec and
+ * the button goes freely; m1:exec shown but m1:done not yet, and every click
+ * in that period is blocked."
+ *
+ * D11 (create-deferred.md §7, ruled 2026-10-04): a held deferred node's
+ * re-pass joins nothing, so the click's tick stays mainline — the button and
+ * the `signal` hole repaint; the `deferred` and `m1` holes are stale readers
+ * of `m1`'s hold (A15) and catch up at its landing; `m1` runs once per
+ * landed input and no landing is dropped. The `{}` holes are render effects,
+ * which is what makes the graph mainline: a derivation reading `m1()` in
+ * between would join by ruling 3 (signals: `tests/createDeferred.test.ts`).
+ */
+const sleep = (ms = 60) => new Promise(r => setTimeout(r, ms));
+
+function makeApp(log: string[]) {
+ return function App() {
+ const [count, setCount] = createSignal(0);
+ const d1 = createDeferred(async () => {
+ const c = count();
+ await sleep();
+ log.push(`d1:done(${c * 2})`);
+ return c * 2;
+ });
+ const m1 = createMemo(async () => {
+ log.push("m1:exec");
+ const d = d1();
+ await sleep();
+ log.push(`m1:done(${d})`);
+ return d;
+ });
+ return (
+
+
loading...}>
+ signal {count()}
+ deferred {d1()}
+ m1 {m1()}
+
+
setCount(v => v + 1)}>Count: {count()}
+
+ );
+ };
+}
+
+const text = (root: HTMLElement) =>
+ `${root.querySelector("i") ? "loading " : ""}[${["s", "d", "m"]
+ .map(id => root.querySelector("#" + id)?.textContent ?? "-")
+ .join(" | ")}] ${root.querySelector("button")!.textContent}`;
+
+test("the button never waits on m1's flight; the holes of the flight hold as stale readers; m1 lands with the input it was asked with", async () => {
+ const log: string[] = [];
+ const App = makeApp(log);
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ const dispose = render(() => , root);
+ flush();
+ const click = () => {
+ root.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ flush();
+ };
+ expect(text(root)).toBe("loading [- | - | -] Count: 0");
+ await sleep(150); // d1(0), then m1(0), land
+ flush();
+ expect(text(root)).toBe("[signal 0 | deferred 0 | m1 0] Count: 0");
+
+ // Click while d1 is in flight: free (D1), the deferred lags.
+ click();
+ expect(text(root)).toBe("[signal 1 | deferred 0 | m1 0] Count: 1");
+
+ // d1(1) lands -> m1 re-runs and holds the frame; the landing is staged with it.
+ await sleep(80);
+ flush();
+ expect(text(root)).toBe("[signal 1 | deferred 0 | m1 0] Count: 1");
+ expect(log.at(-1)).toBe("m1:exec");
+
+ // Clicks while m1 is in flight — the report. Mainline (D11): the button
+ // and the `signal` hole repaint; `deferred`/`m1` keep the committed frame.
+ click();
+ expect(text(root)).toBe("[signal 2 | deferred 0 | m1 0] Count: 2");
+ click();
+ expect(text(root)).toBe("[signal 3 | deferred 0 | m1 0] Count: 3");
+
+ // m1(2) lands: the hold reveals the landing it staged; the stale readers
+ // catch up on it.
+ await sleep(80);
+ flush();
+ expect(text(root)).toBe("[signal 3 | deferred 2 | m1 2] Count: 3");
+
+ // d1(3)=6 lands -> m1 asks 6 -> lands. m1 ran for 0, 2 and 6: once per
+ // landed input, nothing against a stale one, no landing dropped.
+ await sleep(300);
+ flush();
+ expect(text(root)).toBe("[signal 3 | deferred 6 | m1 6] Count: 3");
+ expect(log.filter(l => l === "m1:exec").length).toBe(4); // mount + 3 landings (0, 2, 6)
+ expect(log.filter(l => l.startsWith("m1:done"))).toEqual([
+ "m1:done(0)",
+ "m1:done(2)",
+ "m1:done(6)"
+ ]);
+ dispose();
+});
diff --git a/packages/web/test/harness/__artifacts__/deferred-settled-element.json b/packages/web/test/harness/__artifacts__/deferred-settled-element.json
new file mode 100644
index 0000000000..b89b824ecf
--- /dev/null
+++ b/packages/web/test/harness/__artifacts__/deferred-settled-element.json
@@ -0,0 +1,5 @@
+{
+ "name": "deferred-settled-element",
+ "shell": "loading
",
+ "rest": "Value: 42
"
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx
index 44de282f19..a02d62298d 100644
--- a/packages/web/test/harness/scenarios.tsx
+++ b/packages/web/test/harness/scenarios.tsx
@@ -24,6 +24,7 @@
import {
createSignal,
createMemo,
+ createDeferred,
createEffect,
onSettled,
createProjection,
@@ -266,6 +267,32 @@ function AsyncSettledDiv() {
);
}
+// ---------------------------------------------------------------------------
+// 10a. createDeferred under Loading (docs/create-deferred.md D5/D7): the
+// server renders it as the memo it wraps (one pass, no previous answer), so
+// the chunk shape and the client's claim are scenario 10's; the serialized
+// value is the client's commit #0. The post-hydration update is the one
+// thing that differs: the refetch is CLAMPED — the claimed DOM keeps "Value:
+// 42" while the flight is up (the update signal committed; nothing held it)
+// and swaps to 43 at the landing. The harness sees the landing; the lag is
+// pinned by test/hydration/create-deferred-lag.spec.tsx over this scenario's
+// artifact.
+let refreshDeferredDiv!: () => void;
+function DeferredSettledDiv() {
+ const [version, setVersion] = createSignal(0);
+ refreshDeferredDiv = () => setVersion(v => v + 1);
+ const data = createDeferred(async () => {
+ const v = version();
+ await sleep(5);
+ return 42 + v;
+ });
+ return (
+ loading
}>
+ Value: {data()}
+
+ );
+}
+
// ---------------------------------------------------------------------------
// 10b. Async memo under Loading that settles BEFORE the shell flush. Every
// other async scenario sleeps, so the boundary always takes the streaming
@@ -2399,6 +2426,15 @@ export const scenarios: Scenario[] = [
expectedTextAfterUpdate: "Value: 43",
stableSelector: "div"
},
+ {
+ name: "deferred-settled-element",
+ App: DeferredSettledDiv,
+ async: true,
+ expectedText: "Value: 42",
+ update: () => refreshDeferredDiv(),
+ expectedTextAfterUpdate: "Value: 43",
+ stableSelector: "div"
+ },
{
name: "async-inline-settled",
App: AsyncInlineSettled,
diff --git a/packages/web/test/hydration/create-deferred-lag.spec.tsx b/packages/web/test/hydration/create-deferred-lag.spec.tsx
new file mode 100644
index 0000000000..d10991e9e1
--- /dev/null
+++ b/packages/web/test/hydration/create-deferred-lag.spec.tsx
@@ -0,0 +1,87 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ *
+ * `createDeferred` through SSR and hydration (docs/create-deferred.md D5,
+ * D7): the server renders it as the memo it wraps, the client claims the
+ * server-rendered content as commit #0 (the parity harness pins the claim
+ * over the same artifact — scenario "deferred-settled-element"), and the
+ * first refetch after hydration is already clamped: the claimed DOM keeps the
+ * adopted answer while the flight is up, and swaps at the landing. The
+ * `createMemo` twin (scenario "async-settled-element") would hold the
+ * update and show the new value only at the landing too — what differs is
+ * invisible to a textContent check at the end, so this spec reads the DOM
+ * MID-flight.
+ *
+ * Replays the chunk artifact test/server/hydration-harness.spec.tsx writes
+ * (loaded mode: the full-page refresh case).
+ */
+import { describe, expect, test, vi } from "vitest";
+import { existsSync, readFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { flush } from "solid-js";
+import { hydrate } from "@solidjs/web";
+import { scenarios } from "../harness/scenarios.jsx";
+
+const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__");
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
+
+function loadArtifact(name: string): { shell: string; rest: string } {
+ const file = resolve(artifactsDir, `${name}.json`);
+ if (!existsSync(file)) {
+ throw new Error(
+ `Missing artifact for scenario "${name}". Run the server harness first: ` +
+ `vitest run --config vite.config.server.mjs test/server/hydration-harness.spec.tsx`
+ );
+ }
+ return JSON.parse(readFileSync(file, "utf-8"));
+}
+
+function applyChunk(container: HTMLDivElement, chunk: string, first: boolean) {
+ const scriptRe = /