From c4b36b06a2d6d0473638d13249d7d1247462bcc4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 03:51:25 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(signals,solid):=20createDeferred=20?= =?UTF-8?q?=E2=80=94=20an=20async=20memo=20that=20may=20lag,=20but=20never?= =?UTF-8?q?=20leads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain async memo holds: while a refetch is in flight its readers suspend and the write that caused it is held with it, so every commit in a tick waits on the slowest fetch. createDeferred opts one node out. Once it has a committed value, a refetch is served the previous answer: readers keep rendering, the input's write commits now, and the landing commits on its own schedule. The design is packages/signals/docs/create-deferred.md (propositions D1–D9); tests/createDeferred.test.ts pins one test per proposition plus §2.1, §8.1 and §8.2. Ported onto L2 (the hold model, #3774) — the semantics are the PR's, the mechanism is re-implemented against the running core (create-deferred.md §6 records each seam, and what #3710 did on the pre-L2 core): - The clamp is unchanged in spirit: a compute wrapper (src/deferred.ts) re-opens the commit-#0 loading window (`_loading`) per answered pass, so the pass neither throws nor goes STATUS_PENDING — no frame observes a flight, the flush does not park, the write commits with its tick. The wrapper registers an async-shaped result through handleAsync itself (a projection's pattern) and opens a flight only for one still in the air, which closes #3710's "sync-resolved thenable" known edge. - The verdict is an `affects()` mark whose scope is the flight: a count on the node (affects.ts `mark`/`unmark`), pull-derived coverage through `GlobalQueue._marked` (derived stores included, via pullFamily's read of the derive inside the window), pushed to the verdict readers at open and close. The mark drops at the landing ahead of the write (`GlobalQueue._deferredLanded` in asyncWrite), as landStatus clears a plain memo's pending ahead of it; a rejected flight or a dead node closes at the seam (affects.ts `onSeam`, riding `_releaseAmbientMarks`). No companions, no markWalk, no repoll modes, no verdict.ts change. - 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; a parking flush holds it (holdNode) like any pending node and its landing re-enters the hold (A34 (1)); a committing flush sweeps it as a no-op. commitPendingNode closes the window only for a staged value. - D9: refresh's waiter and until's predicate (signals.ts `watch`) ask the module for an unanswered flight — the predicate through its current deps, hopping a store slot node to its family's derive (`GlobalQueue._slotDerive`, store/store.ts) — and park on it; the flight's close wakes them (`wake` also releases parkers behind an unchanged derivation, which the landing's settle walk cannot reach). - recompute keeps REACTIVE_REASK readable through the pass (the wrapper classifies a quiet re-ask); disposeChildren schedules a seam for a dying loading window (the flight's mark is released there). solid-js: createDeferred hydrates like createMemo through the _hydrateSignalLike adapter slot (as createOptimistic), so hydrating bundles that never call it do not retain it; on the server it is createMemo (D7). Size: +18 B minified in every floor scenario (the landing hook, the re-ask mask, the dispose clause), +48 B in store scenarios (+30 for _slotDerive). Brotli: core floor +14 (7315 → 7329, cap 7330), others −18…+35; eight scenarios exceed caps set 8–14 B above `next`'s sizes by 1–22 B. Caps not raised here (the floor caps are frozen; a Size-Exception is the maintainer's call), exactly as #3710 left them. (cherry picked from commit 06307a469b0be3895fe81c42e278e69611762d58 — every hunk re-resolved against L2; see the design doc's §6) Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/add-create-deferred.md | 8 + packages/signals/docs/RULES-INDEX.md | 16 +- packages/signals/docs/create-deferred.md | 737 +++++++++++------- packages/signals/src/affects.ts | 20 +- packages/signals/src/core/async.ts | 4 + packages/signals/src/core/core.ts | 4 +- packages/signals/src/core/owner.ts | 9 +- packages/signals/src/core/scheduler.ts | 19 +- packages/signals/src/deferred.ts | 248 ++++++ packages/signals/src/index.ts | 1 + packages/signals/src/signals.ts | 77 +- packages/signals/src/store/store.ts | 5 + packages/signals/tests/createDeferred.test.ts | 707 +++++++++++++++++ packages/solid/src/client/hydration.ts | 61 ++ packages/solid/src/index.ts | 3 +- packages/solid/src/server/index.ts | 1 + packages/solid/src/server/signals.ts | 20 + 17 files changed, 1657 insertions(+), 283 deletions(-) create mode 100644 .changeset/add-create-deferred.md create mode 100644 packages/signals/src/deferred.ts create mode 100644 packages/signals/tests/createDeferred.test.ts diff --git a/.changeset/add-create-deferred.md b/.changeset/add-create-deferred.md new file mode 100644 index 0000000000..9b17fbf94a --- /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`, `_deferredSeam`), 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). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 7b49eaa487..fef0bd7c70 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 | @@ -54,31 +54,31 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:137` | — | 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:145` | — | 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:153` | 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:161` | core.ts×3 verdict.ts×2 | createMemo.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:161` | core.ts×3 verdict.ts×2 deferred.ts×1 | createMemo.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:79` | — | latest-isPending-consistency.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:169` | — | 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:177` | — | 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:185` | — | 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:221` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×13 verdict.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 held-restore.test.ts×1 l2-contract.test.ts×7 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 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:193` | 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:51` | async.ts×3 constants.ts×1 lanes.ts×5 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | lane-uninitialized-landing-3648.test.ts×5 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:51` | async.ts×3 constants.ts×1 lanes.ts×5 scheduler.ts×2 verdict.ts×1 deferred.ts×1 map.ts×1 store.ts×3 | createDeferred.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 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:61` | 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 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×3 flight-owned-transaction.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:121` | constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | 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:121` | constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 deferred.ts×2 store.ts×1 | 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:380` | — | 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:387` | — | 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:201` | 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:211` | — | 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:129` | 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:129` | 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×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… | | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:281` | 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:87` | — | action-await-contract.test.ts×2 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:273` | — | 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:71` | 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 held-derivation-not-a-proposal-3612.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:273` | deferred.ts×2 | 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:71` | 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 held-derivation-not-a-proposal-3612.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:95` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×8 effect.ts×1 scheduler.ts×3 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 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 mount-over-foreign-hold-3761.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 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:229` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 scheduler.ts×4 | async-landing-deps-3461.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:103` | boundaries.ts×1 core.ts×1 verdict.ts×1 | 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:111` | 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:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 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:255` | 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 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… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×1 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 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) | diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md index c54d76dcbf..4d18241db9 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,88 @@ 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); +- `disposeChildren` schedules a seam for a dying loading window, as it does + for a held flight's death (a node disposed mid-flight: its landing is + dropped by identity, and the seam sweep closes the flight); +- `GlobalQueue._slotDerive` (store/store.ts installs it): a slot node's + family derive, for D9's walk. + +`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`. +None. The brand is the wrapper; the state is `_loading` plus membership in +two module-level sets and a mark count the node already carries. No +`CONFIG_*` bit, no `NodeExtension` field. ## 7. Spec propositions (to add to `SPEC-ASYNC-SEMANTICS.md` on landing) @@ -562,17 +736,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 +763,18 @@ 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. + +Pinned in `tests/createDeferred.test.ts`, one `describe` per proposition. ## 8. Open questions @@ -606,10 +785,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 +826,72 @@ 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 (`disposeChildren` schedules one). 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 +899,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 +933,28 @@ 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`.~~ No config bit was spent: the brand is + the compute wrapper (§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"). 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) diff --git a/packages/signals/src/affects.ts b/packages/signals/src/affects.ts index ff13c9e14e..c35d1dff85 100644 --- a/packages/signals/src/affects.ts +++ b/packages/signals/src/affects.ts @@ -105,10 +105,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 — @@ -179,6 +196,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 81b5eeb49a..fa51f26034 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -440,6 +440,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/core.ts b/packages/signals/src/core/core.ts index c9343ad758..f0d9f6140d 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -353,7 +353,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 the lane's value for a // lane's node (lanes.ts), else the committed one. diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts index f745e143e1..01b9a54045 100644 --- a/packages/signals/src/core/owner.ts +++ b/packages/signals/src/core/owner.ts @@ -83,12 +83,15 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo if ((node as Computed)._x !== null) (node as Computed)._x!._inFlight = null; // 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. + // stale reader's (REACTIVE_FRAME_READ — a frame that derived from one); + // or a loading window's — a `createDeferred` flight dying open, whose + // mark the seam releases (deferred.ts). Nothing else re-examines a dead + // node, so make sure a seam comes. if ( ((node as Computed)._statusFlags & STATUS_PENDING && (node as Computed)._x?._transaction != null) || - flags & REACTIVE_FRAME_READ + flags & REACTIVE_FRAME_READ || + (node as Computed)._loading ) schedule(); } diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 215447e49f..0d7fc6e610 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -597,6 +597,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). */ @@ -1112,6 +1121,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) { @@ -1122,10 +1137,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..9469235de4 --- /dev/null +++ b/packages/signals/src/deferred.ts @@ -0,0 +1,248 @@ +/** + * `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; `disposeChildren` schedules the seam), 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._loading || el._statusFlags & STATUS_ERROR || el._flags & REACTIVE_DISPOSED) + closeFlight(el); +} + +/** 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) 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 c591a5864e..7970d76095 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 5a25c15311..7074efac6a 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -39,6 +39,7 @@ import { import { REACTIVE_JOINED } from "./core/constants.js"; import { unwrapStatusError } from "./core/error.js"; import { optimisticWrite } from "./core/lanes.js"; +import { deferredCompute, installDeferred, unansweredFlight } from "./deferred.js"; /** * Low-level reactive-cleanup primitive. Registers a callback that runs when @@ -488,6 +489,64 @@ 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(); + return accessor(computed(deferredCompute(compute as any), options)); +} + /** * Creates a reactive effect with **separate compute and effect phases**. * @@ -917,7 +976,12 @@ export function refresh( watch( () => { if (waiter === null) waiter = getOwner() as Computed; - return read(node); + const value = read(node); + // 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 value; }, value => { res(typeof target === "function" ? value : target); @@ -1025,7 +1089,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 2e79348d09..b67f1ce7dc 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -941,6 +941,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..f4286bd46b --- /dev/null +++ b/packages/signals/tests/createDeferred.test.ts @@ -0,0 +1,707 @@ +/** + * createDeferred — an async memo that may lag the global clock, but never + * leads it (docs/create-deferred.md, propositions D1–D10). + * + * 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 transition hold, no async reporter), while `isPending` stays loud. 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 turned into a transition (never leads). + */ +import { + action, + createDeferred, + createEffect, + createErrorBoundary, + createLoadingBoundary, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + 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); + }); + }); +}); diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index d568d2e879..e5f0a1905c 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, @@ -2002,6 +2003,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 279fff7c41..d7752b44fc 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -99,6 +99,7 @@ export { enableHydration, createRoot, createMemo, + createDeferred, createSignal, createStore, createProjection, @@ -265,7 +266,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 285e68c920..16e02a59df 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 f3caa45793..32cb24ba3d 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1379,6 +1379,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). From fbd1c921ce04d2c9d7e02b285f7b49fac6a40889 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 03:51:45 -0700 Subject: [PATCH 2/7] test(signals,web): pin createDeferred's seams on L2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR's tests pin the propositions; these pin what the hold model adds around them (tests/createDeferred.test.ts, "L2 pins"): - Loading boundary: content stays through a refetch (no fallback — the node never pends, so A33's collection has nothing to catch) and an `on` re-arm is inert for a deferred source (create-deferred.md 5). - Inside an action: a flight asked by the action's write is held with the action and its landing reveals at the settle; the action never waits on the flight — it settles, the write reveals, the panel follows. - The verdict is display-ahead (ruling 6): a tracked isPending(d) flips to true in the flush that parks on a sibling's flight, re-derives at the held landing (the plain memo's shape — a render effect has no equality gate), and flips back at the reveal. - D9's push: an equal-value landing beneath an unchanged derivation releases until(() => derived()) (the landing's settle walk stops at a derivation that never went pending; the flight's close wakes the parker); until over a derived store built on the node parks on the flight through the family's derive. - A thenable that resolves synchronously is a landing, not a flight: no reader sees it pending (the §8.3 edge of the pre-L2 implementation). - A node disposed mid-flight drops its landing and its flight closes at the next seam; latest(() => d()) serves committed and its own-async verdict is loud (A8). treeshake.test.ts: the core floor sheds deferred.ts; createDeferred retains deferred.ts and affects.ts (the mark channel) and not the verdict layer, lanes, stores, boundaries or map; refresh/until retain only the authoritative walk, not the clamp or the mark channel. web: a parity-harness scenario, "deferred-settled-element" — the createDeferred twin of the async-memo-under-Loading one — renders through the server harness (D7: the memo's chunk shape) and hydrates in both replay modes with the server span claimed; create-deferred-lag.spec.tsx replays its artifact and reads the DOM mid-flight: the adopted answer stays while the first refetch flies, and the landing swaps it in place. INTERNALS-ASYNC-STATE.md §0 gains the deferred module in the L2 module map. Co-authored-by: Claude via Cursor --- .../signals/docs/INTERNALS-ASYNC-STATE.md | 2 + packages/signals/docs/RULES-INDEX.md | 30 +- packages/signals/tests/createDeferred.test.ts | 373 +++++++++++++++++- packages/signals/tests/treeshake.test.ts | 37 ++ .../deferred-settled-element.json | 5 + packages/web/test/harness/scenarios.tsx | 36 ++ .../hydration/create-deferred-lag.spec.tsx | 87 ++++ 7 files changed, 551 insertions(+), 19 deletions(-) create mode 100644 packages/web/test/harness/__artifacts__/deferred-settled-element.json create mode 100644 packages/web/test/hydration/create-deferred-lag.spec.tsx diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index 2b26cd324a..ba9255e8c9 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; `disposeChildren` schedules a seam for a dying loading window. + ## 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 fef0bd7c70..b5f5a91c04 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -52,7 +52,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:323` | — | 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:331` | 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:137` | — | 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:145` | — | 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:145` | — | 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:153` | 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:161` | core.ts×3 verdict.ts×2 deferred.ts×1 | createMemo.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:79` | — | latest-isPending-consistency.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… | @@ -63,12 +63,12 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:193` | 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:51` | async.ts×3 constants.ts×1 lanes.ts×5 scheduler.ts×2 verdict.ts×1 deferred.ts×1 map.ts×1 store.ts×3 | createDeferred.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 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:61` | 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 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×3 flight-owned-transaction.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:121` | constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 deferred.ts×2 store.ts×1 | 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:121` | 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 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:380` | — | 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:387` | — | 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:201` | 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:211` | — | 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:129` | 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×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:129` | 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:281` | 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:87` | — | action-await-contract.test.ts×2 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:273` | deferred.ts×2 | 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… | @@ -77,7 +77,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 scheduler.ts×4 | async-landing-deps-3461.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:103` | boundaries.ts×1 core.ts×1 verdict.ts×1 | 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:111` | 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:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 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… | +| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 createDeferred.test.ts×1 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:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×1 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 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 @@ -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 | 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/tests/createDeferred.test.ts b/packages/signals/tests/createDeferred.test.ts index f4286bd46b..e5813d0afe 100644 --- a/packages/signals/tests/createDeferred.test.ts +++ b/packages/signals/tests/createDeferred.test.ts @@ -4,10 +4,18 @@ * * 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 transition hold, no async reporter), while `isPending` stays loud. 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 turned into a transition (never leads). + * 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, and `latest`. */ import { action, @@ -23,6 +31,7 @@ import { createStore, flush, isPending, + latest, refresh, until, type SourceAccessor @@ -704,4 +713,360 @@ describe("createDeferred", () => { 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 drops its landing; the flight closes at the next seam", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + let rows!: () => string; + let dispose!: () => void; + createRoot(d => { + dispose = d; + rows = createDeferred(() => fetcher.fetch(n())); + createRenderEffect(rows, () => {}); + }); + flush(); + fetcher.resolve(1); + await settle(); + setN(2); + flush(); + expect(isPending(rows)).toBe(true); + dispose(); + flush(); + fetcher.resolve(2); + await settle(); + // The late landing is dropped by identity; a dead node freezes at its + // last committed value (#3024). + 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); + }); + }); }); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 8f8faab795..1b8894df97 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -73,6 +73,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", @@ -480,6 +481,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/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 23e303ef58..40e61d2411 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, @@ -263,6 +264,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 @@ -2331,6 +2358,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(); + } + }); +}); From 72cb1e03bfbadb88bf701cf6da649f5202d4f150 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 07:40:18 -0700 Subject: [PATCH 3/7] signals(createDeferred): drop the dispose-clause seam; re-pin disposal to the seam that runs disposeChildren no longer forces a seam for a dying loading window (the -5 B cut). A createDeferred node disposed mid-flight outside a flush keeps its mark until the next seam that runs; its late landing is still dropped by identity and the dead node freezes at its last committed value. The disposal pin is re-titled to that behaviour and asserts both halves (mark persists after the out-of-flush dispose; released by the next write's flush). Docs and the changeset follow (the changeset's stale _deferredSeam mention corrected to _slotDerive / onSeam). --- .changeset/add-create-deferred.md | 2 +- .../signals/docs/INTERNALS-ASYNC-STATE.md | 2 +- packages/signals/docs/create-deferred.md | 12 ++++++----- packages/signals/src/core/owner.ts | 11 +++++----- packages/signals/src/deferred.ts | 5 +++-- packages/signals/tests/createDeferred.test.ts | 20 ++++++++++++++++--- 6 files changed, 34 insertions(+), 18 deletions(-) diff --git a/.changeset/add-create-deferred.md b/.changeset/add-create-deferred.md index 9b17fbf94a..f91a36af66 100644 --- a/.changeset/add-create-deferred.md +++ b/.changeset/add-create-deferred.md @@ -5,4 +5,4 @@ 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`, `_deferredSeam`), 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). +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). diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index ba9255e8c9..13172c3c25 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -27,7 +27,7 @@ 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; `disposeChildren` schedules a seam for a dying loading window. +**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. 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 diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md index 4d18241db9..65c5463319 100644 --- a/packages/signals/docs/create-deferred.md +++ b/packages/signals/docs/create-deferred.md @@ -709,9 +709,6 @@ What core carries, all of it in the floor: - `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); -- `disposeChildren` schedules a seam for a dying loading window, as it does - for a held flight's death (a node disposed mid-flight: its landing is - dropped by identity, and the seam sweep closes the flight); - `GlobalQueue._slotDerive` (store/store.ts installs it): a slot node's family derive, for D9's walk. @@ -872,8 +869,13 @@ 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 (`disposeChildren` schedules one). Nothing leaks: -the set is the lifecycle, the count follows it. +→ 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 diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts index 01b9a54045..83470e48ea 100644 --- a/packages/signals/src/core/owner.ts +++ b/packages/signals/src/core/owner.ts @@ -83,15 +83,14 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo if ((node as Computed)._x !== null) (node as Computed)._x!._inFlight = null; // 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); - // or a loading window's — a `createDeferred` flight dying open, whose - // mark the seam releases (deferred.ts). Nothing else re-examines a dead - // node, so make sure a seam comes. + // 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. (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) || - flags & REACTIVE_FRAME_READ || - (node as Computed)._loading + flags & REACTIVE_FRAME_READ ) schedule(); } diff --git a/packages/signals/src/deferred.ts b/packages/signals/src/deferred.ts index 9469235de4..5f86a9c921 100644 --- a/packages/signals/src/deferred.ts +++ b/packages/signals/src/deferred.ts @@ -177,8 +177,9 @@ function closeFlight(el: Computed): void { * 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; `disposeChildren` schedules the seam), a window closed with no - * `asyncWrite` (a stream's buffered yield). */ + * 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) diff --git a/packages/signals/tests/createDeferred.test.ts b/packages/signals/tests/createDeferred.test.ts index e5813d0afe..589cecbb59 100644 --- a/packages/signals/tests/createDeferred.test.ts +++ b/packages/signals/tests/createDeferred.test.ts @@ -1022,9 +1022,10 @@ describe("createDeferred", () => { }); describe("L2 — disposal and latest", () => { - it("a node disposed mid-flight drops its landing; the flight closes at the next seam", async () => { + 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 => { @@ -1032,18 +1033,31 @@ describe("createDeferred", () => { 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(); - // The late landing is dropped by identity; a dead node freezes at its - // last committed value (#3024). + 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); }); From 5eb6c86cb52e25f17fc13fec156ad8a0e4359224 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 21:58:28 -0700 Subject: [PATCH 4/7] =?UTF-8?q?test(signals):=20pin=20mizulu's=20#3710=20s?= =?UTF-8?q?hape=20on=20L2=20=E2=80=94=20a=20plain=20async=20memo=20over=20?= =?UTF-8?q?a=20deferred=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced on the PR head and on the port alike: once the downstream plain memo's flight holds the frame, the deferred node is a held node (its landing is staged in that hold), and a mainline write to its input re-passes it — recompute's T4 join parks the whole tick, the outside reader's run included, until the downstream memo lands. The same pass restages the served committed value over the held landing (m1 refetches the old input; the landing is lost until a later flight re-lands it). Pinned live: the decided halves (a write never waits on the deferred's own flight, D1; the downstream memo's hold is A15's; with the downstream memo deferred too, nothing ever waits). Pinned skipped (current behaviour, pending ruling): the reported half, with the two readings recorded in create-deferred.md 8.3. No core change. --- packages/signals/docs/RULES-INDEX.md | 4 +- packages/signals/docs/create-deferred.md | 25 +++ packages/signals/tests/createDeferred.test.ts | 169 +++++++++++++++++- 3 files changed, 195 insertions(+), 3 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index b5f5a91c04..504dcf8216 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:169` | — | 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:177` | — | 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:185` | — | 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:221` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×13 verdict.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 held-restore.test.ts×1 l2-contract.test.ts×7 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 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:221` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×13 verdict.ts×2 | async-chain-supersession.test.ts×1 createDeferred.test.ts×2 first-observer-stale-reader.test.ts×1 held-restore.test.ts×1 l2-contract.test.ts×7 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 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:193` | 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:51` | async.ts×3 constants.ts×1 lanes.ts×5 scheduler.ts×2 verdict.ts×1 deferred.ts×1 map.ts×1 store.ts×3 | createDeferred.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 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:61` | 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 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×3 flight-owned-transaction.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… | @@ -78,7 +78,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:103` | boundaries.ts×1 core.ts×1 verdict.ts×1 | 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:111` | 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:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 createDeferred.test.ts×1 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:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×1 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 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… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×1 store.ts×4 | a34-writes-then-derivations.test.ts×2 createDeferred.test.ts×1 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 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) | diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md index 65c5463319..d1ab49e065 100644 --- a/packages/signals/docs/create-deferred.md +++ b/packages/signals/docs/create-deferred.md @@ -950,6 +950,31 @@ written mid-action — and is not this primitive's to pin.) 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** (mizulu's report on + #3710, 2026-10-04; same on the PR and on L2). Shape: `count` → `d1 = +createDeferred` → `m1 = createMemo(async)`, both under a frame reader; + `count` also read by a reader 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 write to + `count` (committed, held by no one) re-passes `d1`, and `recompute`'s + "a pass over a held derivation joins its transaction" (T4) joins the whole + tick: the outside reader's run parks until `m1` lands — the report. The + same pass stages the served committed value over the held landing, so + `m1` re-fetches against the old input and the landing is lost until a + later flight re-lands it. Two readings, undecided by D1–D9 (D2 covers a + flight asked against a _staged_ write; this write is mainline and the + hold is a downstream plain memo's): (1) the join stands — `d1` is held + and a tick cannot finish in two parts (A34 (1), T4) — and the wrapper's + pass must then leave the held landing in place rather than restage the + served value; (2) a deferred re-ask is a lagging question, not an answer + — it stages and replaces nothing, so it joins nothing: the write commits + mainline (the outside reader repaints; the frame reader is a stale reader + re-derived at the landing, A15) and the held landing stays staged for + `m1`'s hold. Either fix touches `recompute`'s join or the wrapper's + result under a hold. Current behaviour pinned skipped (`tests/ +createDeferred.test.ts`, "pending ruling"); the shape with `m1` deferred + too never waits and is pinned live. Until ruled, the user-side form is to + defer the downstream memo as well. 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 diff --git a/packages/signals/tests/createDeferred.test.ts b/packages/signals/tests/createDeferred.test.ts index 589cecbb59..6bc007e1f7 100644 --- a/packages/signals/tests/createDeferred.test.ts +++ b/packages/signals/tests/createDeferred.test.ts @@ -15,7 +15,9 @@ * 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, and `latest`. + * sync-resolved thenable, disposal, `latest`, and mizulu's #3710 shape (a + * plain async memo over a deferred node; its reported half is pending a + * ruling and pinned skipped). */ import { action, @@ -1083,4 +1085,169 @@ describe("createDeferred", () => { 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); + }); + + // TODO(ruling): the reported half. Once `m1`'s flight holds the frame, + // `d1` is a held node (its landing is staged in that transaction), and + // a mainline write to `count` — committed, held by no one — re-passes + // `d1`, and `recompute`'s "a pass over a held derivation joins its + // transaction" (L2 T4) joins the whole tick: the button's run parks + // with it until `m1` lands. The same pass also stages the served + // committed value over the held landing (`staged 4 → 0`), so `m1` + // re-fetches against the OLD input and the landing is lost until a + // later flight re-lands it. Two readings: (1) the join stands — `d1` + // is held, its re-pass is a pass over a held derivation, and a tick + // cannot finish in two parts (A34 (1), T4); the wrapper's pass must then + // leave the held landing in place rather than restage the served value. + // (2) A deferred's re-ask is a lagging question, not an answer: it + // stages nothing and replaces nothing, so it joins nothing — the write + // commits mainline (the button repaints, the boundary's reader is a + // stale reader re-derived at the landing, A15) and the held landing + // stays staged for `m1`'s hold. D1–D9 do not decide it (D2 covers a + // flight asked against a STAGED write; this write is mainline and the + // hold is a downstream plain memo's). Current behaviour, pinned so the + // change is visible when ruled. + describe.skip("a write to the input while the downstream plain memo is in flight (current behaviour, pending ruling)", () => { + it("joins the downstream hold: the button waits for m1, and the held landing is restaged as the served value", 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]); + // Click while m1 is in flight. + p.setCount(2); + flush(); + expect(p.out.button).toBe(1); // held — mizulu's report + expect(p.out.view).toBe("signal 1 deferred 0 m1 0"); + // The joined pass re-ran m1 against the served 0: the landed 2 is + // gone from the hold, and m1 refetches the old input. + expect(p.m1Fetch.inFlight()).toEqual([2, 0]); + p.m1Fetch.resolve(2); + await settle(); + expect(p.out.view).toBe("signal 1 deferred 0 m1 0"); // 2's landing dropped + p.m1Fetch.resolve(0); + await settle(); + // The hold lands with d1 at 0 — the landing it staged is lost until + // d1(2) lands. + expect(p.out.view).toBe("signal 2 deferred 0 m1 0"); + expect(p.out.button).toBe(2); + p.d1Fetch.resolve(2); + await settle(); + expect(p.m1Fetch.inFlight()).toEqual([4]); + p.m1Fetch.resolve(4); + await settle(); + expect(p.out.view).toBe("signal 2 deferred 4 m1 4"); + }); + }); + }); }); From 453c73b9b1e1cba28bf59f5966c80ef5129cf244 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 22:12:31 -0700 Subject: [PATCH 5/7] docs(createDeferred): review brief; mizulu's shape is stop 6 --- packages/signals/docs/create-deferred.md | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md index d1ab49e065..afb3db67a6 100644 --- a/packages/signals/docs/create-deferred.md +++ b/packages/signals/docs/create-deferred.md @@ -1030,3 +1030,64 @@ 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 (el._config & +CONFIG_HELD) … joinPassTx(tx)`) over a deferred node held because a + downstream plain memo's hold staged its landing, re-passed by a mainline + write to its input — and, in the same pass, the wrapper's served + committed value compared against the staging and restaged over the held + landing. _Does a deferred re-ask join the hold of a downstream plain memo + (reading 1), or is a lagging question not an answer and joins nothing + (reading 2)?_ §8.3 ("A held deferred node re-passed by a mainline write") + has the two readings; `tests/createDeferred.test.ts` pins today's + behaviour skipped ("pending ruling") beside the live pins of the decided + halves. + +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/core.ts` — the `REACTIVE_REASK` mask in `recompute`'s pre-pass + wipe (1 code line); +- `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. From 9bcde62b8815557c740f164329dca6e7ee9b515f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 22:39:10 -0700 Subject: [PATCH 6/7] fix(createDeferred): a re-ask over a held landing keeps it; the window stays open under a newer question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deferred pass over a held node (a downstream hold staged its landing; a write to an input re-passed it) returns the staging as its result: handleAsync's served committed value, returned as the result, was compared against the staging (INV-11) and restaged over the landing — the hold's readers re-derived against the old input and the landing was lost until the next flight re-landed it. And the seam sweep re-opens a window a commit closed under a question it did not answer (the hold landing commits the older landing while the re-ask is in the air), so isPending stays true while the node's own flight is in flight (D4). Deferred-module only; pinned on the text-memo graph. --- packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/src/deferred.ts | 25 +++++++-- packages/signals/tests/createDeferred.test.ts | 56 +++++++++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 504dcf8216..f8d57011f7 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -71,14 +71,14 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:129` | 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:281` | 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:87` | — | action-await-contract.test.ts×2 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:273` | deferred.ts×2 | 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… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | 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:71` | 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 held-derivation-not-a-proposal-3612.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:95` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×8 effect.ts×1 scheduler.ts×3 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 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 mount-over-foreign-hold-3761.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 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:229` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 scheduler.ts×4 | async-landing-deps-3461.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:103` | boundaries.ts×1 core.ts×1 verdict.ts×1 | 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:111` | 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:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 createDeferred.test.ts×1 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:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×1 store.ts×4 | a34-writes-then-derivations.test.ts×2 createDeferred.test.ts×1 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 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… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createDeferred.test.ts×1 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 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) | @@ -119,7 +119,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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 | spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality | +| 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/src/deferred.ts b/packages/signals/src/deferred.ts index 5f86a9c921..42d70bf2ba 100644 --- a/packages/signals/src/deferred.ts +++ b/packages/signals/src/deferred.ts @@ -183,8 +183,14 @@ function closeFlight(el: Computed): void { function seam(): void { if (open.size === 0) return; for (const el of open) - if (!el._loading || el._statusFlags & STATUS_ERROR || el._flags & REACTIVE_DISPOSED) - closeFlight(el); + 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. */ @@ -231,8 +237,19 @@ export function deferredCompute(compute: (prev: T) => T): (prev: T) => T { } throw e; } - if (async && el._loading) openFlight(el, reask); - else { + 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); } diff --git a/packages/signals/tests/createDeferred.test.ts b/packages/signals/tests/createDeferred.test.ts index 6bc007e1f7..f722c19028 100644 --- a/packages/signals/tests/createDeferred.test.ts +++ b/packages/signals/tests/createDeferred.test.ts @@ -1194,6 +1194,62 @@ describe("createDeferred", () => { 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); + }); + }); + // TODO(ruling): the reported half. Once `m1`'s flight holds the frame, // `d1` is a held node (its landing is staged in that transaction), and // a mainline write to `count` — committed, held by no one — re-passes From 721c54703a0c279f1f01aaf96c0cdbe22dc82bfe Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 22:42:29 -0700 Subject: [PATCH 7/7] =?UTF-8?q?signals(createDeferred):=20a=20lagging=20qu?= =?UTF-8?q?estion=20is=20not=20an=20answer=20=E2=80=94=20a=20held=20deferr?= =?UTF-8?q?ed=20re-pass=20joins=20nothing=20(ruled=202026-10-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D11. CONFIG_DEFERRED (1 << 19) brands the node (createDeferred sets it); recompute's T4 arm takes neither the join nor the mainline-publish branch for it. A held deferred node — its landing staged by a downstream async memo's hold — re-passed by a mainline write keeps the held landing (the common part, previous commit) and the write's tick stays mainline: the button repaints, the frame's readers of the held flight are stale readers (A15) re-derived at the hold's landing, the new flight's landing re-enters the hold (A34 (1)). Limit, L2-general: a derivation between the held memo and the frame joins by ruling 3 (pinned beside a pure-L2 control). Pins: the ruling on the playground's real graph (render-effect holes), the derivation limit, the pure-L2 control; the user's exact graph as a web spec. Docs: D11 in create-deferred.md §7, §6.4/§6.5, §8.3 rewritten from two readings to the ruling, §10 stop 6; INTERNALS §0; changeset. Floor cost: +12 B minified (the brand check). --- .changeset/add-create-deferred.md | 2 +- .../signals/docs/INTERNALS-ASYNC-STATE.md | 2 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/docs/create-deferred.md | 122 +++++++---- packages/signals/src/core/constants.ts | 5 + packages/signals/src/core/core.ts | 9 +- packages/signals/src/signals.ts | 7 +- packages/signals/tests/createDeferred.test.ts | 201 ++++++++++++++---- .../test/create-deferred-mizulu-3710.spec.tsx | 112 ++++++++++ 9 files changed, 377 insertions(+), 85 deletions(-) create mode 100644 packages/web/test/create-deferred-mizulu-3710.spec.tsx diff --git a/.changeset/add-create-deferred.md b/.changeset/add-create-deferred.md index f91a36af66..8e71274ad5 100644 --- a/.changeset/add-create-deferred.md +++ b/.changeset/add-create-deferred.md @@ -5,4 +5,4 @@ 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). +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 13172c3c25..2177067861 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -27,7 +27,7 @@ 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. A node disposed mid-flight outside a flush keeps its mark until the next seam that runs (`disposeChildren` schedules none for it). +**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 diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index f8d57011f7..9d933966cf 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -78,7 +78,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:103` | boundaries.ts×1 core.ts×1 verdict.ts×1 | 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:111` | 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:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 createDeferred.test.ts×1 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:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 deferred.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createDeferred.test.ts×1 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 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… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:255` | 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 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) | diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md index afb3db67a6..8e57eed437 100644 --- a/packages/signals/docs/create-deferred.md +++ b/packages/signals/docs/create-deferred.md @@ -710,7 +710,10 @@ What core carries, all of it in the floor: - `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. + 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). @@ -723,9 +726,12 @@ createMemo` identity, D7); exported from both `solid-js` entries. ### 6.5 Constants -None. The brand is the wrapper; the state is `_loading` plus membership in +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 -`CONFIG_*` bit, no `NodeExtension` field. +`NodeExtension` field. ## 7. Spec propositions (to add to `SPEC-ASYNC-SEMANTICS.md` on landing) @@ -770,6 +776,21 @@ Numbered provisionally; renumber into the A-series when adopted. - ~~**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. @@ -935,8 +956,9 @@ written mid-action — and is not this primitive's to pin.) 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`.~~ No config bit was spent: the brand is - the compute wrapper (§6.5). +- `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 @@ -950,31 +972,39 @@ written mid-action — and is not this primitive's to pin.) 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** (mizulu's report on - #3710, 2026-10-04; same on the PR and on L2). Shape: `count` → `d1 = -createDeferred` → `m1 = createMemo(async)`, both under a frame reader; - `count` also read by a reader 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 write to - `count` (committed, held by no one) re-passes `d1`, and `recompute`'s - "a pass over a held derivation joins its transaction" (T4) joins the whole - tick: the outside reader's run parks until `m1` lands — the report. The - same pass stages the served committed value over the held landing, so - `m1` re-fetches against the old input and the landing is lost until a - later flight re-lands it. Two readings, undecided by D1–D9 (D2 covers a - flight asked against a _staged_ write; this write is mainline and the - hold is a downstream plain memo's): (1) the join stands — `d1` is held - and a tick cannot finish in two parts (A34 (1), T4) — and the wrapper's - pass must then leave the held landing in place rather than restage the - served value; (2) a deferred re-ask is a lagging question, not an answer - — it stages and replaces nothing, so it joins nothing: the write commits - mainline (the outside reader repaints; the frame reader is a stale reader - re-derived at the landing, A15) and the held landing stays staged for - `m1`'s hold. Either fix touches `recompute`'s join or the wrapper's - result under a hold. Current behaviour pinned skipped (`tests/ -createDeferred.test.ts`, "pending ruling"); the shape with `m1` deferred - too never waits and is pinned live. Until ruled, the user-side form is to - defer the downstream memo as well. +- **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 @@ -1065,17 +1095,24 @@ Six places to read first, each with the one question it should answer. 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 (el._config & -CONFIG_HELD) … joinPassTx(tx)`) over a deferred node held because a - downstream plain memo's hold staged its landing, re-passed by a mainline - write to its input — and, in the same pass, the wrapper's served - committed value compared against the staging and restaged over the held - landing. _Does a deferred re-ask join the hold of a downstream plain memo - (reading 1), or is a lagging question not an answer and joins nothing - (reading 2)?_ §8.3 ("A held deferred node re-passed by a mainline write") - has the two readings; `tests/createDeferred.test.ts` pins today's - behaviour skipped ("pending ruling") beside the live pins of the decided - halves. +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); @@ -1085,8 +1122,9 @@ through the pass (the wrapper classifies quiet re-asks by it); 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); + 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 diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index fce31a35c7..d84d70f788 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -140,6 +140,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 f0d9f6140d..5d36d76a94 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, @@ -301,7 +302,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 && !(el._statusFlags & STATUS_UNINITIALIZED)) { // Published mainline, it is not held: the frame this pass builds is diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 7074efac6a..c59cb20ebd 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, createRoot, dispose, @@ -544,7 +545,11 @@ export function createDeferred( options?: MemoOptions ): SourceAccessor { installDeferred(); - return accessor(computed(deferredCompute(compute as any), options)); + 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); } /** diff --git a/packages/signals/tests/createDeferred.test.ts b/packages/signals/tests/createDeferred.test.ts index f722c19028..7870de1108 100644 --- a/packages/signals/tests/createDeferred.test.ts +++ b/packages/signals/tests/createDeferred.test.ts @@ -1,6 +1,6 @@ /** * createDeferred — an async memo that may lag the global clock, but never - * leads it (docs/create-deferred.md, propositions D1–D10). + * 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, @@ -16,8 +16,9 @@ * 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; its reported half is pending a - * ruling and pinned skipped). + * 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, @@ -1250,60 +1251,184 @@ describe("createDeferred", () => { }); }); - // TODO(ruling): the reported half. Once `m1`'s flight holds the frame, - // `d1` is a held node (its landing is staged in that transaction), and - // a mainline write to `count` — committed, held by no one — re-passes - // `d1`, and `recompute`'s "a pass over a held derivation joins its - // transaction" (L2 T4) joins the whole tick: the button's run parks - // with it until `m1` lands. The same pass also stages the served - // committed value over the held landing (`staged 4 → 0`), so `m1` - // re-fetches against the OLD input and the landing is lost until a - // later flight re-lands it. Two readings: (1) the join stands — `d1` - // is held, its re-pass is a pass over a held derivation, and a tick - // cannot finish in two parts (A34 (1), T4); the wrapper's pass must then - // leave the held landing in place rather than restage the served value. - // (2) A deferred's re-ask is a lagging question, not an answer: it - // stages nothing and replaces nothing, so it joins nothing — the write - // commits mainline (the button repaints, the boundary's reader is a - // stale reader re-derived at the landing, A15) and the held landing - // stays staged for `m1`'s hold. D1–D9 do not decide it (D2 covers a - // flight asked against a STAGED write; this write is mainline and the - // hold is a downstream plain memo's). Current behaviour, pinned so the - // change is visible when ruled. - describe.skip("a write to the input while the downstream plain memo is in flight (current behaviour, pending ruling)", () => { - it("joins the downstream hold: the button waits for m1, and the held landing is restaged as the served value", async () => { + /** 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.out.view).toBe("signal 1 deferred 0 m1 0"); expect(p.m1Fetch.inFlight()).toEqual([2]); - // Click while m1 is in flight. p.setCount(2); flush(); - expect(p.out.button).toBe(1); // held — mizulu's report + // 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 joined pass re-ran m1 against the served 0: the landed 2 is - // gone from the hold, and m1 refetches the old input. - expect(p.m1Fetch.inFlight()).toEqual([2, 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 1 deferred 0 m1 0"); // 2's landing dropped - p.m1Fetch.resolve(0); - await settle(); - // The hold lands with d1 at 0 — the landing it staged is lost until - // d1(2) lands. - expect(p.out.view).toBe("signal 2 deferred 0 m1 0"); + expect(p.out.view).toBe("signal 2 deferred 2 m1 2"); expect(p.out.button).toBe(2); p.d1Fetch.resolve(2); await settle(); - expect(p.m1Fetch.inFlight()).toEqual([4]); 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/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(); +});