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)); + * + *
+ * {row => } + *
+ * ``` + */ +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())); + * + *
+ * {row => } + *
+ * ``` + */ +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()}

+
+ +
+ ); + }; +} + +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": "" +} \ 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 = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(scriptRe)].map(m => m[1]); + const stripped = chunk.replace(scriptRe, ""); + if (first) container.innerHTML = stripped; + else container.insertAdjacentHTML("beforeend", stripped); + for (const s of scripts) (0, eval)(s); +} + +describe("createDeferred hydrates like createMemo and lags on its first refetch", () => { + test("the adopted answer stays on screen while the refetch flies; the landing swaps it in place", async () => { + const scenario = scenarios.find(s => s.name === "deferred-settled-element")!; + const { shell, rest } = loadArtifact(scenario.name); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let dispose: (() => void) | undefined; + try { + applyChunk(container, shell, true); + if (rest) applyChunk(container, rest, false); + dispose = hydrate(() => , container); + flush(); + await sleep(10); + flush(); + const div = container.querySelector("div")!; + expect(div.textContent).toBe("Value: 42"); + + // The refetch: the write commits (nothing holds it), the claimed node + // keeps serving the adopted answer, no fallback. + scenario.update!(); + flush(); + expect(container.textContent).toBe("Value: 42"); + expect(container.querySelector("p")).toBeNull(); + + await sleep(50); + flush(); + expect(container.textContent).toBe("Value: 43"); + expect(container.querySelector("div"), "the claimed element survives the landing").toBe(div); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + dispose?.(); + container.remove(); + } + }); +});