diff --git a/.changeset/create-deferred.md b/.changeset/create-deferred.md new file mode 100644 index 0000000000..0bb39eff81 --- /dev/null +++ b/.changeset/create-deferred.md @@ -0,0 +1,8 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +--- + +`createDeferred(fn, options?)`: an async memo that may lag, but never leads. Before its first answer it is an ordinary async memo (readers suspend to ``, or `loadingValue` shows). After it, a refetch holds nothing: readers keep the previous answer until the new one lands, the rest of the screen moves on, and `isPending` reports the refetch on the node and on everything derived from it, store keys included. A refetch that read a held value lands with that hold. A rejection errors like any memo. `refresh(d)` is a quiet re-ask, and `await refresh(d)` and `until` wait for the landing. It is `createMemo` on the server and hydrates like one. + +The rules: F-1 and F-2 are amended for declared lag, and D-40 / S-41 are new. The `affects()` mark core moved to its own module (`affects-marks.ts`), which `createDeferred` shares. A mark on the source of a derived store now covers that store's keys. diff --git a/documentation/solid-2.0/MIGRATION.md b/documentation/solid-2.0/MIGRATION.md index 3015f4d15f..af0b280069 100644 --- a/documentation/solid-2.0/MIGRATION.md +++ b/documentation/solid-2.0/MIGRATION.md @@ -1062,7 +1062,7 @@ If you need a standard Observable/AsyncIterable interface for external consumers - **`produce`** → now the default store setter behavior (draft-first) - **`createMutable` / `modifyMutable`** → use `createStore` with draft setters - **`from` / `observable`** → async iterators -- **`createDeferred`** → removed; handle outside Solid +- **`createDeferred`** → same name, new meaning: an async memo that may lag but never leads (keeps its last answer while a refetch is in flight; `isPending` reports the refetch). 1.x's idle-time deferral and `timeoutMs` are gone; with a synchronous function it behaves like `createMemo` - **`indexArray`** → use `mapArray` with `keyed: false` - **`resetErrorBoundaries`** → no longer needed (error boundaries heal automatically) - **`enableScheduling`** → removed diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index fccdb42cd4..da1e5d7a79 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -334,6 +334,27 @@ An `` flipping to its fallback is a boundary going back to its fallback --- +## F. Declared lag (F-1, F-2, F-7, F-9) + +### D-40. A deferred source lags its readers and never leads them + +**From:** F-1, F-2 (both amended 2026-10-14 for declared lag), F-7, F-9, F-4, F-12. **Scenarios:** S-41. + +`createDeferred(fn)` is an async memo whose author declared that its previous answer is acceptable while the next one loads. Its first load is a first load like any: a reader suspends to a boundary or holds (F-2, F-4), and with `loadingValue` the declared first paint shows instead, verdict-quiet (D-30). After its first answer: + +- **Nothing waits for it.** A change that re-asks it holds nothing on its account: its readers keep the answer they show, and the rest of the change shows now. An unready source it reads (`createDeferred(() => query())`) is the same: the node keeps its answer, nothing pends through it. +- **Its landing is a change of its own.** It shows when it lands, unless it lands into a hold (next bullet); an unrelated hold does not keep it back. +- **It never leads.** A re-ask that read a held value derived from that hold's world, so its answer lands into that hold and shows with its commit (F-7). Its readers that also read a held value see the committed one, as any reader does. +- **A held answer stays held.** Once a hold has taken its landing (a reader downstream is pending and staged it), a change that re-asks it still holds nothing: the change shows now, and the hold keeps the landing it took and shows it at its commit. +- **The verdict sees it.** `isPending` of the node, and of everything derived from it (a memo over it, a derived store's keys), is `true` from the re-ask until the landing (F-9: a new question). A `refresh()` re-ask is quiet. The verdict and the landed value change in one frame. +- **Truth readers wait for it.** `await refresh(d)` delivers the landing; `until`'s predicate does not see the stale answer of a source it derives from (the reader's own declaration: `resolve` takes the first settled value and does not wait). +- **Errors are not lag.** A rejected re-ask errors like any memo (F-12); the stale answer never masks it. +- **On the server it is `createMemo`** (everything is first load there). + +**Collapses:** `docs/create-deferred.md` D1–D11 (2026-09-08, D11 ruled 2026-10-04; its lane-landing mechanism is superseded, see its §6). **Status:** standing, built 2026-10-14 (`tests/create-deferred.test.ts`, `tests/create-deferred-3710.test.ts`). + +--- + ## Flagged — rulings that cannot cite a fundamental, stale archive text, mistakes | item | finding | disposition | diff --git a/packages/signals/docs/RULES-FUNDAMENTALS.md b/packages/signals/docs/RULES-FUNDAMENTALS.md index 8bc32d74ef..2226570e67 100644 --- a/packages/signals/docs/RULES-FUNDAMENTALS.md +++ b/packages/signals/docs/RULES-FUNDAMENTALS.md @@ -32,6 +32,7 @@ These are the only words the fundamentals use. Internal names (transaction, stag - **First load.** The wait before a source has its first answer. A first load is _loading_, not a change: there is no previous content to keep. A memo created during a hold that reads the held change (_born held_, D-2) is loading in the same sense: it has no value the screen can show until that hold commits. A read of something loading suspends — to a boundary, or into the hold (F-2, F-4) — and `isPending` of it gives no verdict, because there is nothing shown to be stale (F-9). - **Question / re-ask.** A source's inputs are its question. An input changing asks a new question; `refresh()`, polling, an action's confirming refetch re-ask the same one. - **Display-ahead reader.** `latest(x)`, `isPending(x)`, an optimistic value: a reader that shows a held change (or the verdict about it) before the hold reveals it. +- **Declared lag.** `createDeferred(fn)`: a source whose author has declared that its previous answer is an acceptable answer while the next one loads. After its first answer, its own re-asks hold nothing and its readers keep the answer they show until the new one lands (D-40). It is the counterpart of a display-ahead reader: that one shows a held change early, this one shows a change's consequence late, and neither is a hold. - **Lane.** The world a display-ahead read opens: the screen plus what it shows ahead. What is rendered from a display-ahead read lives in its lane, and the lane holds _itself_ on its own async — a hold of its own, separate from the hold it reads ahead of (F-6). - **Flush.** The unit of visibility. Writes become visible at the flush that carries them; a manual `flush()` ends a tick. @@ -39,19 +40,19 @@ These are the only words the fundamentals use. Internal names (transaction, stag ## F-1. The screen is whole -**Immutable.** Every frame the screen shows is consistent: a value and everything on screen derived from it changed in the same frame. The screen never shows a new value beside a stale derivation of it, and never a derivation ahead of its input. This holds for every kind of on-screen reader — a plain binding, a memo between, a boundary's content, a `latest()` view and what is rendered from it — and in every world the screen can show: the committed one, a held one, an optimistic one. +**Immutable.** Every frame the screen shows is consistent: a value and everything on screen derived from it changed in the same frame. The screen never shows a new value beside a stale derivation of it, and never a derivation ahead of its input. This holds for every kind of on-screen reader — a plain binding, a memo between, a boundary's content, a `latest()` view and what is rendered from it — and in every world the screen can show: the committed one, a held one, an optimistic one. The one exception is the author's declaration of lag (amended 2026-10-14): a `createDeferred` node keeps showing its previous answer beside the change that re-asked it, and so does what is rendered from it, until its own answer lands. It may lag the screen; it never leads it (F-7, D-40). -**What it does not say.** It does not say that two _unrelated_ changes must land together (F-2 decides what waits for what). It does not say that imperative, untracked reads are part of a frame — they read the screen as it stands (D-29, D-34). It does not say control and content must agree when the content is _absent_ by the author's choice (a fallback is content: F-4). +**What it does not say.** It does not say that two _unrelated_ changes must land together (F-2 decides what waits for what). It does not say that imperative, untracked reads are part of a frame — they read the screen as it stands (D-29, D-34). It does not say control and content must agree when the content is _absent_ by the author's choice (a fallback is content: F-4). It does not say content the author declared may lag agrees with the control that re-asked it (D-40). -**Collapses:** A10 (`[isPending(x), x()]` atomic), A15's no-tearing core, A17's "no-tearing is an effect-level concern", A29/#3408 (`Panel: 1` beside `Count: 0` was the violation), #3479 "the frame is whole", #3851 (`content 1` beside `x = 0`), fuzzer finding F6 (#3811, the disarmed half — a lane revealing ahead of content a disarmed boundary is still showing), OL-R26, OS-R9, #3761 (ruled against), N1 (reverted). **Derived:** D-1–D-3, D-9, D-10, D-25–D-28, D-34. **Scenarios:** S-2–S-4, S-11, S-30, S-31, S-39. +**Collapses:** A10 (`[isPending(x), x()]` atomic), A15's no-tearing core, A17's "no-tearing is an effect-level concern", A29/#3408 (`Panel: 1` beside `Count: 0` was the violation), #3479 "the frame is whole", #3851 (`content 1` beside `x = 0`), fuzzer finding F6 (#3811, the disarmed half — a lane revealing ahead of content a disarmed boundary is still showing), OL-R26, OS-R9, #3761 (ruled against), N1 (reverted). **Derived:** D-1–D-3, D-9, D-10, D-25–D-28, D-34, D-40. **Scenarios:** S-2–S-4, S-11, S-30, S-31, S-39, S-41. ## F-2. A change that needs async to be shown is held; the screen keeps what it showed -**Immutable.** When showing a change would put a render effect on screen that consumes async that has not answered, the change is _held_: nothing of it shows, the screen keeps the content it had, and when every such async has landed, the change and all of it reveal as one frame. Which async counts: any new async that render effect consumes, whatever its source, unless a boundary catches the render effect (F-3, F-4). A first load in a branch the change mounts counts, and so does a memo created during the hold, when a render effect outside a boundary consumes it (D-2, D-6): the branch does not attach until they have answered, and the screen never shows it with an empty slot. `` is how a change escapes the hold. This is the navigation model: click, page B's data loads, page B appears whole. An `action` is the explicit form: its writes are held until its body has returned and everything they put in flight has landed. +**Immutable.** When showing a change would put a render effect on screen that consumes async that has not answered, the change is _held_: nothing of it shows, the screen keeps the content it had, and when every such async has landed, the change and all of it reveal as one frame. Which async counts: any new async that render effect consumes, whatever its source, unless a boundary catches the render effect (F-3, F-4), or the async is a declared-lag source re-asking after its first answer (`createDeferred`, amended 2026-10-14): its readers keep the answer they show and hold nothing (D-40). A first load in a branch the change mounts counts, and so does a memo created during the hold, when a render effect outside a boundary consumes it (D-2, D-6): the branch does not attach until they have answered, and the screen never shows it with an empty slot. `` is how a change escapes the hold. This is the navigation model: click, page B's data loads, page B appears whole. An `action` is the explicit form: its writes are held until its body has returned and everything they put in flight has landed. -**What it does not say.** It does not say that what an armed boundary catches makes the change wait (F-4: the catch is the exception, by design). It does not say a new memo joins a hold by being created — it joins by reading a held value, sync or async (D-2); a memo that reads nothing held or pending shows now. It does not say a display-ahead reader holds (F-6). It does not say the hold never waits on work that has never committed: that was the direction rule's time half (2026-10-06, #3820; drafted as F-8, withdrawn 2026-10-09 — the ID is not reused). A first load a render effect consumes outside a boundary holds the change like any other async. +**What it does not say.** It does not say that what an armed boundary catches makes the change wait (F-4: the catch is the exception, by design). It does not say a new memo joins a hold by being created — it joins by reading a held value, sync or async (D-2); a memo that reads nothing held or pending shows now. It does not say a display-ahead reader holds (F-6), or that a declared-lag source's re-ask does (D-40); its first load does, like any. It does not say the hold never waits on work that has never committed: that was the direction rule's time half (2026-10-06, #3820; drafted as F-8, withdrawn 2026-10-09 — the ID is not reused). A first load a render effect consumes outside a boundary holds the change like any other async. -**Collapses:** A15 (entanglement), A29 (the read entry, born held, ruling A), A26 (actions), A34 (1) (a write to a held slot joins), the tick ruling (2026-10-04), #3800's direction, the #2933 navigation model, recovery 2.1/3.1/3.2/4.2, the 2026-10-07 words "in general transitions can hold on any unready read". **Derived:** D-1–D-4, D-6–D-11, D-28. **Scenarios:** S-1–S-13, S-39. +**Collapses:** A15 (entanglement), A29 (the read entry, born held, ruling A), A26 (actions), A34 (1) (a write to a held slot joins), the tick ruling (2026-10-04), #3800's direction, the #2933 navigation model, recovery 2.1/3.1/3.2/4.2, the 2026-10-07 words "in general transitions can hold on any unready read". **Derived:** D-1–D-4, D-6–D-11, D-28, D-40. **Scenarios:** S-1–S-13, S-39, S-41. ## F-3. A reader holds only while it is the display @@ -129,7 +130,7 @@ These are the only words the fundamentals use. Internal names (transaction, stag ## Reading the fundamentals together -- **What waits:** F-2 says a change waits for the async its on-screen content needs. F-3 removes a reader that is no longer the display. F-4 removes what an armed boundary catches from it — a transition's reads, or a lane's own async — and the boundary shows a fallback in its place. F-6 removes display-ahead readers from it. +- **What waits:** F-2 says a change waits for the async its on-screen content needs. F-3 removes a reader that is no longer the display. F-4 removes what an armed boundary catches from it — a transition's reads, or a lane's own async — and the boundary shows a fallback in its place. F-6 removes display-ahead readers from it. A declared-lag source's re-asks never enter it (F-1, F-2, D-40). - **What shows meanwhile:** F-1 says whatever shows is a whole frame. F-5 says a disarmed boundary's old content is that frame's. F-6 says display-ahead readers are the one way to show the change early; they bring their own derivations with them and hold themselves, in their own lane, on their own async. F-7 says nothing else from the change shows early. - **Verdicts and visibility:** F-9 says when the screen admits it is stale. F-10 says when a write exists at all. F-11 says what an optimistic value is and for how long. F-12 says what an error is and is not. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 8640841552..00c5eca60c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -52,23 +52,23 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:351` | — | derived-write-then-derivation-3733.test.ts×1 errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | | A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:359` | boundaries.ts×2 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | | A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | | A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:213` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:221` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | +| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | create-deferred-3710.test.ts×2 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | | A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:408` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | | A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:415` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | | A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:229` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … | | A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:239` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | -| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | affects.ts×2 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×3 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | +| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | affects-marks.ts×1 affects.ts×1 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 create-deferred-3710.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 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:309` | projection.ts×2 | derived-presence-async-3726.test.ts×2 seedless-derived-store.test.ts×1 uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:111` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:301` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | @@ -77,7 +77,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:257` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:131` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:139` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | -| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×4 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | +| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×4 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | | A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations @@ -119,7 +119,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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-11 | live | `docs/INTERNALS-ASYNC-STATE.md:216` | core.ts×1 | create-deferred-3710.test.ts×1 spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality | ## RUL — store rulings | id | status | defined | cited in src | cited in tests | statement (at definition) | diff --git a/packages/signals/docs/SCENARIOS.md b/packages/signals/docs/SCENARIOS.md index fbcdc23ce4..40dc4ecdca 100644 --- a/packages/signals/docs/SCENARIOS.md +++ b/packages/signals/docs/SCENARIOS.md @@ -814,3 +814,28 @@ const data = createMemo(() => fetchOrFail(count())); // rejects for every co | ✗ | | after 1: `1 \| loading` (the error clearing for a pending); or `0` held by the errored flight | | `` never catches a pending: `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning (A6). + +## F. Declared lag (D-40) + +### S-41. Independent panels over one selection: the write shows now, each panel lags to its own landing — D-40 + +_Pinned: `tests/create-deferred.test.ts`._ + +```tsx +const [period, setPeriod] = createSignal(1); +const fast = createDeferred(() => fetchFast(period())); // lands in 200ms +const slow = createDeferred(() => fetchSlow(period())); // lands in 10s +

Period {period()}

+ fast()) }} rows={fast()} /> + slow()) }} rows={slow()} /> +``` + +| # | Action | Screen | `isPending` fast / slow | Notes | +| --- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | +| 0 | both first loads land | `Period 1` · fast 1 · slow 1 | false / false | the first loads held (or suspended to a boundary) like any | +| 1 | `setPeriod(2); flush()` | `Period 2` · fast 1 · slow 1 | true / true | the write shows now; nothing holds on the panels | +| 2 | `fetchFast(2)` lands | `Period 2` · **fast 2** · slow 1 | false / true | the landing is its own change | +| 3 | `fetchSlow(2)` lands | `Period 2` · fast 2 · **slow 2** | false / false | | +| 1′ | instead: plain async memos | `Period 1` · fast 1 · slow 1 until 3 | true / true | the default: one hold, gated on the slowest (F-2) | +| 1″ | instead: `period` is held (another reader of it is still loading) | `Period 1` · fast 1 · slow 1 | true / true | a re-ask that read the held period lands into that hold and shows with its commit (F-7) | +| ✗ | | after 1: `Period 1` held for the panels; or after 1″: `fast 2` beside `Period 1` | | the first is the hold D-40 removes; the second leads the hold | diff --git a/packages/signals/docs/create-deferred.md b/packages/signals/docs/create-deferred.md index c54d76dcbf..62ee2943ab 100644 --- a/packages/signals/docs/create-deferred.md +++ b/packages/signals/docs/create-deferred.md @@ -1,17 +1,15 @@ # 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: **built 2026-10-14** (`src/deferred.ts`, `tests/create-deferred.test.ts`). +> The ruling is D-40 in `RULES-DERIVED.md`, with F-1 and F-2 amended for +> declared lag. The semantics below (§2–§5, §7) stand as designed. The +> mechanism in §6 and §8.0–8.1 was written against the pre-L2 engine and is +> superseded by §6.0: the lane landing path it relied on was carved out, and +> under L2 it is not needed. 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. §9 records why +> this is a migration primitive: combined with `loadingValue` it is the +> per-node opt-out from coordination that 1.0 → 2.0 migrations lack. ## 1. The problem @@ -30,12 +28,12 @@ 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 +142,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 +160,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 +281,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,20 +400,58 @@ 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). | +| 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.0 As built (2026-10-14, L2) + +`src/deferred.ts`, pay-for-use; core carries three small edits (`recompute` +keeps `REACTIVE_REASK` through the pass and skips the T4 join for a +`CONFIG_LAGS` node, and `watch` asks `_lagging` for truth readers). It +carries #3710's pins (`tests/create-deferred-3710.test.ts`, D1–D11), and +supersedes that PR. + +- **Read side: the loading window.** Once the node has a real answer, every + pass opens `_loading` before it asks, so a flight serves the committed + value and an unready source parks the node. The node carries + `CONFIG_REDERIVE`, so a source going pending re-derives it (and it parks) + instead of pushing pending through it to its readers. Without that, the + wrapping form held the whole flush. +- **Landing: a change of its own.** Under L2 a landing that nothing holds + is published by the flush that carries it; an unrelated hold never adopts + it. No lane is needed. To never lead, a landing checks its dependencies: + a held one means the pass read the held world (an input changed after the + pass would have superseded the flight), so the node is put on that hold + (`holdNode`) and `setSignal` stages the answer there. +- **D11: a held re-ask joins nothing.** When a downstream hold staged the + node's landing, a write that re-asks it re-passes a held node. The node + carries `CONFIG_LAGS` once answered, so `recompute` does not join the + pass to the hold (the write commits mainline), and the wrapper serves the + staged landing as the pass's value, so it is not restaged over with the + committed one. +- **Verdict: the mark core** (`src/affects-marks.ts`, split out of `affects.ts`): + a mark whose lifetime is the flight, not counted while a `refresh()` + re-ask is in flight. Coverage is the probe-time dependency walk, which + now hops a store slot to its family's derive; the repoll reaches a derived + store's leaf readers through the derive's `fam`. +- **Truth readers** (`refresh`'s waiter, `until`): `watch` throws + `NotReadyError` for a deferred flight the value derives from + (`GlobalQueue._lagging`), so they park; the mark's repoll at the landing + re-runs them even when the landing equals the committed value (not through + a derived store, whose leaves re-run them when its derive writes). +- **Streams:** the flight ends at the first yield. + +The sections below are the original sketch, kept for the reasoning. ### 6.1 Read contract = a loading window that never closes @@ -423,12 +459,12 @@ The loading window already implements the read side exactly: ```ts // core/async.ts — handleAsync -if (el._loading) return el._value; // serve committed, no transition +if (el._loading) return el._value; // serve committed, no transition globalQueue.initTransition(resolveTransition(el)); throw new NotReadyError(context!); // core/core.ts — recompute catch -if (notReady && el._loading) parkLoadingWindow(el, e); // register for settle, +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." ``` diff --git a/packages/signals/src/affects-marks.ts b/packages/signals/src/affects-marks.ts new file mode 100644 index 0000000000..b06bbc4491 --- /dev/null +++ b/packages/signals/src/affects-marks.ts @@ -0,0 +1,100 @@ +// The mark core: a declared not-final state on a node that reads pending +// (`isPending` → true) on the node and everything derived from it, without +// holding anything or entangling a reader. Two declarers share it: +// `affects()` (affects.ts — a mark scoped to a transaction, with a store +// half) and `createDeferred` (deferred.ts — a mark whose lifetime is the +// node's own flight). Split out so the second does not ship the first. +// +// Representation: a count on the marked node (`_x._marks`). Coverage of the +// derivations is pull-derived at probe time — `isPending`'s read walks the +// probed node's dependencies for a marked one (verdict.ts → `_marked`) — so +// nothing is stored downstream and a mid-window recompute strands nothing. +// The push half is the verdict readers' re-derivation when a node's marks +// start and end (`repoll`): the only notification a mark makes. + +import { + CONFIG_AUTHORITATIVE, + CONFIG_SLOT_NODE, + CONFIG_VERDICT, + REACTIVE_RECOMPUTING_DEPS, + STATUS_ERROR +} from "./core/constants.js"; +import { ext } from "./core/core.js"; +import { enqueueSub } from "./core/heap.js"; +import { GlobalQueue } from "./core/scheduler.js"; +import type { Computed, Signal } from "./core/types.js"; + +export type Marked = Signal | Computed; + +/** Live marks, every declarer — the gate on the probe's walk. */ +let active = 0; + +/** The verdict readers downstream of `node` — through derivations, stopping + * at effects — re-derive: the mark's one notification (a probe's answer + * changed with no value notifying anyone). Truth readers (`until`, the + * `refresh` waiter) too, which a lagging flight parked (deferred.ts) — but + * not through a derived store, whose leaves change only once its derive + * re-runs: they re-derive at that write. */ +export function repoll( + node: Marked, + seen = new Set>(), + readers = CONFIG_VERDICT | CONFIG_AUTHORITATIVE +): 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 & readers) enqueueSub(sub); + if (!(sub as any)._type) repoll(sub, seen, readers); + // A derived store's derive: its leaves' readers derive from it too + // (store/projection.ts; a live target's property, has and keys nodes). + const fam = (sub as any).fam; + if (fam) + for (const t of fam.live) + for (const o of [t.n, t.h, { k: t.k, d: t.dk }]) + if (o) for (const k of Reflect.ownKeys(o)) o[k] && repoll(o[k], seen, CONFIG_VERDICT); + } +} + +/** A node is covered by a live mark iff it carries one, or derives — through + * its current dependencies — from one that does. A store slot derives from + * its family's derive (a derived store's leaves are not the derive's + * dependents, store/projection.ts), so coverage hops there. A real error + * outranks an inherited mark (A24 (c)): coverage does not flow through an + * errored node (a direct mark on one still reads pending). Mid-recompute, + * only the validated prefix of the dependency list is this pass's. */ +function marked(el: Marked, seen: Set): boolean { + if (el._x !== null && el._x._marks !== 0) return true; + const c = el as Computed; + if (c._statusFlags & STATUS_ERROR || seen.has(el)) return false; + seen.add(el); + if (c._config & CONFIG_SLOT_NODE) { + const derive: Marked | null | undefined = (c as any)._host?.fam?.node; + return derive != null && marked(derive, seen); + } + const tail = c._flags & REACTIVE_RECOMPUTING_DEPS ? c._depsTail : undefined; + if (tail !== null) + for (let d = c._deps ?? null; d !== null; d = d._nextDep) { + if (marked(d._dep, seen)) return true; + if (d === tail) break; + } + return false; +} + +/** One mark on `node`. The declarer repolls once its bookkeeping is done. */ +export function mark(node: Marked): void { + ext(node)._marks++; + active++; +} + +/** Drops one mark from `node`: true when it was the last — the node's + * verdict readers have re-derived. */ +export function unmark(node: Marked): boolean { + active--; + if (--node._x!._marks !== 0) return false; + repoll(node); + return true; +} + +GlobalQueue._marked = el => active !== 0 && marked(el, new Set()); +GlobalQueue._mark = mark; diff --git a/packages/signals/src/affects.ts b/packages/signals/src/affects.ts index 6b7252beba..1d5c146a30 100644 --- a/packages/signals/src/affects.ts +++ b/packages/signals/src/affects.ts @@ -11,85 +11,34 @@ // data joins nothing through it. A mark is never a re-ask, so a declared // reload's own `refresh()` cannot silence it; a real error outranks it. // -// Representation: a count on the marked node (`_x._marks`), listed with its -// scope (the transaction's `_marks`, or the ambient list). Coverage of the -// derivations is pull-derived at probe time — `isPending`'s read walks the -// probed node's dependencies for a marked one (verdict.ts → `_marked`) — so -// nothing is stored downstream and a mid-window recompute strands nothing. -// The push half is the verdict readers' re-derivation at registration and at -// release (`repoll`): the only notification a mark makes. +// Representation: the mark core (affects-marks.ts) — a count on the marked +// node, coverage pull-derived at probe time, readers repolled at registration +// and release. Here: each mark's scope (the transaction's `_marks`, or the +// ambient list) and the store half. // // Installed on `GlobalQueue` when imported: a program that never declares a // mark carries none of this. -import { - REACTIVE_RECOMPUTING_DEPS, - STATUS_ERROR, - STATUS_PENDING, - CONFIG_SLOT_NODE, - CONFIG_VERDICT, - $REFRESH -} from "./core/constants.js"; -import { ext } from "./core/core.js"; +import { STATUS_PENDING, CONFIG_SLOT_NODE, $REFRESH } from "./core/constants.js"; import { emitDiagnostic } from "./core/dev.js"; -import { enqueueSub } from "./core/heap.js"; import { flushTransaction, GlobalQueue, resolveTx, schedule } from "./core/scheduler.js"; -import type { Computed, Signal } from "./core/types.js"; +import type { Computed } from "./core/types.js"; +import { mark, repoll, unmark, type Marked } from "./affects-marks.js"; import type { Accessor } from "./signals.js"; import { installStoreAffects, releaseMarkScope, storeMarks } from "./store/affects.js"; import { $TARGET, type Store } from "./store/types.js"; -type Marked = Signal | Computed; - -/** Live marks, every scope — the gate on the probe's walk. */ -let active = 0; /** Marks declared outside a transaction: released at the next seam. */ const ambient: Marked[] = []; -/** The verdict readers downstream of `node` — through derivations, stopping - * at effects — re-derive: the mark's one notification (a probe's answer - * changed with no value notifying anyone). */ -function repoll(node: Marked, 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) enqueueSub(sub); - if (!(sub as any)._type) repoll(sub, seen); - } -} - -/** A node is covered by a live mark iff it carries one, or derives — through - * its current dependencies — from one that does. A real error outranks an - * inherited mark (A24 (c)): coverage does not flow through an errored node - * (a direct mark on one still reads pending). Mid-recompute, only the - * validated prefix of the dependency list is this pass's. */ -function marked(el: Marked, seen: Set): boolean { - if (el._x !== null && el._x._marks !== 0) return true; - const c = el as Computed; - if (c._statusFlags & STATUS_ERROR || seen.has(el)) return false; - seen.add(el); - const tail = c._flags & REACTIVE_RECOMPUTING_DEPS ? c._depsTail : undefined; - if (tail !== null) - for (let d = c._deps ?? null; d !== null; d = d._nextDep) { - if (marked(d._dep, seen)) return true; - if (d === tail) break; - } - return false; -} - /** Releases one scope's marks (a landed transaction's, the ambient list at * the seam): a node whose last mark drops re-derives its verdict readers. */ function release(nodes: Marked[]): void { for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; - active--; - if (--n._x!._marks === 0) { - repoll(n); - // A store mark's carrier: its scope (and the marks the nodes born in - // its window inherited) goes with it. - releaseMarkScope(n); - } + // A store mark's carrier: its scope (and the marks the nodes born in + // its window inherited) goes with the last mark. + if (unmark(n)) releaseMarkScope(n); } nodes.length = 0; } @@ -106,10 +55,6 @@ function register(node: Marked): void { } repoll(node); } -function mark(node: Marked): void { - ext(node)._marks++; - active++; -} /** * Declares that in-flight work will change the targeted data: the source — @@ -177,8 +122,6 @@ function invalid(detail: string): never { throw new Error(message); } -GlobalQueue._marked = el => active !== 0 && marked(el, new Set()); -GlobalQueue._mark = mark; GlobalQueue._releaseMarks = release; GlobalQueue._releaseAmbientMarks = parked => { if (ambient.length === 0) return; diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 8605118995..5ad51989c0 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -142,6 +142,10 @@ export const CONFIG_VERDICT = 1 << 15; * promise over an expression (`resolve`/`until`, signals.ts: the pass reads * the error and rejects, or re-asks a pending source). */ export const CONFIG_REDERIVE = 1 << 14; +/** An answered `createDeferred` node (deferred.ts): held because a + * downstream hold staged its landing, its re-pass is a lagging question, + * not an answer, and joins no transaction (D-40). */ +export const CONFIG_LAGS = 1 << 19; /** The reader wants the truth, not the lane's guess (`until`, signals.ts): * a read of displayed optimism serves the base the guess covers, makes the * pass no lane's, and the truth landing wakes it even when it confirms the diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index bbb0b1e728..51c9fb4c4b 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -28,6 +28,7 @@ import { CONFIG_PROMOTED, CONFIG_STAGED, CONFIG_HELD_FRAME, + CONFIG_LAGS, CAUGHT_REARMED, CONFIG_SYNC, CONFIG_TRANSPARENT, @@ -324,7 +325,9 @@ 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) { + // An answered deferred node's re-pass is a lagging question (D-40): + // it joins nothing, so the write that reached it commits mainline. + if (tx._lane || el._config & CONFIG_LAGS) { } else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx, GlobalQueue._fresh?.(el, el._statusFlags & STATUS_UNINITIALIZED)); else if ( @@ -380,7 +383,7 @@ 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); + el._flags = REACTIVE_RECOMPUTING_DEPS | (el._flags & (REACTIVE_ZOMBIE | REACTIVE_REASK)); el._time = clock; // The pass's previous value: its staging, else a lane node's lane value // when it has one (lanes.ts), else the committed one. diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 5357c5d3cd..15db1cf78b 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -690,6 +690,9 @@ export class GlobalQueue implements IQueue { declare static _storePark: ((t: Transaction) => void) | undefined; // `affects()` marks (affects.ts): the probe's coverage test, the releases // at a landing and at the seam (ambient marks). + /** `createDeferred` (deferred.ts): a deferred node with a flight up that + * `c` derives from, if any — a truth reader waits on it. */ + declare static _lagging: ((c: Computed) => Computed | undefined) | undefined; declare static _marked: ((el: Signal | Computed) => boolean) | undefined; declare static _releaseMarks: ((nodes: Signal[]) => void) | undefined; declare static _releaseAmbientMarks: ((parked: Transaction | null) => void) | undefined; diff --git a/packages/signals/src/deferred.ts b/packages/signals/src/deferred.ts new file mode 100644 index 0000000000..f20cacb3db --- /dev/null +++ b/packages/signals/src/deferred.ts @@ -0,0 +1,191 @@ +// `createDeferred` — an async memo that may lag, but never leads (D-40, +// docs/create-deferred.md). +// +// Once the node has a committed value, its own non-final work is invisible +// to the graph: its readers keep the committed value and hold nothing, its +// landings are changes of their own, and only the verdict (`isPending`) and +// the truth readers (`until`, `refresh`'s waiter) see the flight. +// +// Built from parts the engine already has, so core pays nothing for it: +// +// - **Read side: the loading window** (`_loading`, async.ts). Every pass of +// a node with a committed value opens the window before it asks, so a +// flight serves the committed value instead of throwing, and an unready +// source parks the node instead of pending it. `CONFIG_REDERIVE` makes a +// source going pending a question for the node's pass (which parks) rather +// than a status pushed through it to its readers. +// - **Verdict: a mark** (affects-marks.ts) whose lifetime is the flight — +// started when a pass asks a new question (a `refresh()` re-ask is quiet, F-9), +// dropped at the landing, the rejection, a sync answer or the node's death. +// - **Never leads:** a flight that lands while an input it read is held +// derived from that hold's world, so it lands into the hold (`holdNode`; +// `setSignal` stages it there). An input that changed after the pass +// would have superseded the flight, so a held input at the landing is one +// the pass read held. +// - **Truth readers** wait the flight out (`GlobalQueue._lagging`, signals.ts +// `watch`): `await refresh(d)` delivers the landing, `until` sees only +// settled values. + +import { + CONFIG_HELD, + CONFIG_LAGS, + CONFIG_REDERIVE, + CONFIG_SLOT_NODE, + NOT_PENDING, + REACTIVE_DISPOSED, + REACTIVE_REASK, + REACTIVE_RECOMPUTING_DEPS, + STATUS_UNINITIALIZED +} from "./core/constants.js"; +import { handleAsync, isThenable } from "./core/async.js"; +import { cleanup, computed, getOwner, NotReadyError } from "./core/index.js"; +import { GlobalQueue, holdNode, schedule, txOf } from "./core/scheduler.js"; +import type { Computed } from "./core/types.js"; +import { mark, repoll, unmark } from "./affects-marks.js"; +import { accessor, type MemoOptions, type SourceAccessor } from "./signals.js"; + +/** Deferred nodes with a flight up. */ +const lagging = new Set>(); + +/** The deferred node with a flight up that `c`'s value derives from, if + * any: `c` itself, or one reached through its current dependencies. */ +function lagOf(c: Computed, seen: Set>): Computed | undefined { + if (lagging.has(c)) return c; + if (seen.has(c)) return; + seen.add(c); + if (c._config & CONFIG_SLOT_NODE) return lagOf((c as any)._host?.fam?.node ?? c, seen); + const tail = c._flags & REACTIVE_RECOMPUTING_DEPS ? c._depsTail : undefined; + if (tail !== null) + for (let d = c._deps ?? null; d !== null; d = d._nextDep) { + const l = lagOf(d._dep as Computed, seen); + if (l) return l; + if (d === tail) break; + } +} +GlobalQueue._lagging = c => (lagging.size !== 0 ? lagOf(c, new Set()) : undefined); + +/** + * Creates an async memo that may lag, but never leads. Before its first + * answer it is an ordinary async memo: readers suspend to the nearest + * ``. After it, a refetch holds nothing: readers keep the last + * committed value until the new one lands, and the rest of the screen moves + * on without it. `isPending` reports the refetch, on the node and on what + * derives from it. Use it for independent widgets (dashboards, feeds, + * search results) where the previous answer with a "refreshing" indicator is + * better than waiting with everything else. + * + * - It never shows a value ahead of a held change: a refetch that read a + * held value lands with that hold. + * - A rejected refetch errors like any memo; the stale value never masks it. + * - `refresh(d)` is a quiet re-ask, and `await refresh(d)` and `until` + * wait for the landing rather than the stale value. + * - Combine with `loadingValue` for a node that never suspends at all. + * + * @example + * ```tsx + * const rows = createDeferred(() => fetchRows(store.period)); + * + *
rows()) }}> + * {row => } + *
+ * ``` + */ +export function createDeferred( + fn: (prev?: T) => T | PromiseLike | AsyncIterable, + options?: MemoOptions +): SourceAccessor { + let node!: Computed; + /** The flight in force: a promise, a stream, or the error an unready + * source parked the node with. */ + let flight: unknown = null; + let marked = false; + let answered = false; + /** The flight `r` is over. An async answer (`held`) arriving while an + * input is held lands into that hold first. */ + const land = (r: unknown, held?: boolean) => { + if (r === null || flight !== r) return; + flight = null; + lagging.delete(node); + if (held) + for (let d = node._deps; d !== null; d = d._nextDep) + if (d._dep._config & CONFIG_HELD) { + holdNode(node, txOf(d._dep)); + break; + } + if (marked) { + marked = false; + unmark(node); + schedule(); + } + }; + const ask = (r: unknown) => { + flight = r; + lagging.add(node); + if (!marked && !(node._flags & REACTIVE_REASK)) { + marked = true; + mark(node); + repoll(node); + } + }; + /** Registers the flight and serves the pass's value: the committed one, + * or over a held landing the landing itself, which a lagging question + * does not replace (D-40) — restaged as the committed value, the hold's + * readers would re-derive against the old input. */ + const serve = (r: PromiseLike | AsyncIterable): T => { + const v = handleAsync(node, r); + return node._config & CONFIG_HELD && node._pendingValue !== NOT_PENDING + ? (node._pendingValue as T) + : v; + }; + const end = () => node._flags & REACTIVE_DISPOSED && land(flight); + return accessor( + computed(prev => { + node = getOwner() as Computed; + node._config |= CONFIG_REDERIVE; + cleanup(end); + // Answered: the node has a real committed answer — not a first load, + // not a `loadingValue` window still open (both are loading, F-9). + // Sticky, and read before this wrapper ever opens the window itself. + const open = (answered ||= !(node._statusFlags & STATUS_UNINITIALIZED) && !node._loading); + if (open) ((node._loading = true), (node._config |= CONFIG_LAGS)); + let r: T | PromiseLike | AsyncIterable; + try { + r = fn(prev); + } catch (e) { + // The wrapping form: an unready source parks the node in the window, + // and the wait is its flight until the source's settle re-runs it. + if (open && e instanceof NotReadyError) ask(e); + throw e; + } + if (open && typeof r === "object" && r !== null) { + if (isThenable(r as PromiseLike)) { + ask(r); + const done = () => land(r, true); + (r as PromiseLike).then(done, done); + return serve(r as PromiseLike); + } + const iterate = (r as AsyncIterable)[Symbol.asyncIterator]; + if (iterate) { + // A stream's flight ends at its first yield (or its end). + const it = iterate.call(r); + let first = true; + const done = () => land(s, true); + const s: AsyncIterator = { + // Registered ahead of the engine's own reaction to the same + // promise, as for a plain flight: verdict and value in one flush. + next: v => { + const p = it.next(v); + if (first) ((first = false), p.then(done, done)); + return p; + }, + return: v => it.return?.(v) ?? Promise.resolve({ done: true, value: v }) + }; + ask(s); + return serve({ [Symbol.asyncIterator]: () => s }); + } + } + land(flight); + return r; + }, options) + ); +} diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index ef6a4b8799..bc7c59921b 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -115,6 +115,7 @@ export type { export { mapArray, repeat, type Maybe } from "./map.js"; export * from "./store/index.js"; export { affects } from "./affects.js"; +export { createDeferred } from "./deferred.js"; export { flatten } from "./flatten.js"; export { createLoadingBoundary, createErrorBoundary } from "./boundaries.js"; export { createRevealOrder, type RevealOrder } from "./reveal.js"; diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 7a7e28e5dc..9ec63ca0a9 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -786,6 +786,10 @@ function watch( return; } const c = getOwner() as Computed; + // A truth reader waits out a `createDeferred` flight its value derives + // from, which serves everyone else the stale answer (D-40). + const lag = config & CONFIG_AUTHORITATIVE && GlobalQueue._lagging?.(c); + if (lag) throw new NotReadyError(lag); // (Outside a flush a mainline pass joins through `passTx`.) const joined = flushTransaction ?? passTx; if ( diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index e0dde6cf33..6550da182c 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -345,6 +345,9 @@ function createProjectionInternal( }, nodeOptions); node._config &= ~CONFIG_AUTO_DISPOSE; fam.node = node; + // The derive's leaves are not its dependents: a mark's repoll + // (affects-marks.ts) reaches their readers through this. + (node as any).fam = fam; return { store, node } as { store: Refreshable>; node: Computed }; } diff --git a/packages/signals/tests/create-deferred-3710.test.ts b/packages/signals/tests/create-deferred-3710.test.ts new file mode 100644 index 0000000000..005ca64682 --- /dev/null +++ b/packages/signals/tests/create-deferred-3710.test.ts @@ -0,0 +1,1426 @@ +/** + * createDeferred — an async memo that may lag the global clock, but never + * leads it (docs/create-deferred.md, propositions D1–D11; D-40). + * + * Ported from #3710, whose implementation these pins were written against; + * comments naming its internals (`handleAsync`'s serve branch, the seam + * sweep) describe the behaviour, which deferred.ts reaches by its own route. + * + * Once initialized, the node's own non-finality is invisible to the graph: + * readers are served the committed value during a refetch (no NotReadyError, + * no STATUS_PENDING, no frame observing a flight — the flush does not park), + * while `isPending` stays loud through the `affects()` mark channel (A24). + * The landing commits on the plain path — ambient when the flight was asked + * against committed inputs, joining the input's hold when it was asked + * against a staged write that a sibling's flight parked (never leads: the + * node is held with the transaction like any pending node of the flush). + * + * The first `describe`s are the PR's (#3710), one per proposition; the "L2 + * pins" at the end fix what the hold model adds: the boundary interplay, the + * flight inside an action, the verdict's display-ahead under a parked flush, + * the D9 push through unchanged derivations and derived stores, the + * sync-resolved thenable, disposal, `latest`, and mizulu's #3710 shape (a + * plain async memo over a deferred node: the common part, the D11 ruling on + * the playground's real graph, and its limit — a derivation between the + * held memo and the frame joins by ruling 3, L2-general). + */ +import { + action, + createDeferred, + createEffect, + createErrorBoundary, + createLoadingBoundary, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest, + refresh, + until, + type SourceAccessor +} from "../src/index.js"; + +afterEach(() => flush()); + +const tick = () => new Promise(r => setTimeout(r, 0)); +async function settle(rounds = 4) { + for (let i = 0; i < rounds; i++) { + await tick(); + flush(); + } +} + +/** A fetcher whose flights resolve only when told to, keyed by argument. */ +function deferredFetcher(compute: (arg: A) => T) { + const pending = new Map void; reject: (e: unknown) => void }>(); + return { + fetch(arg: A): Promise { + return new Promise((res, rej) => { + pending.set(arg, { resolve: () => res(compute(arg)), reject: rej }); + }); + }, + resolve(arg: A) { + pending.get(arg)!.resolve(); + pending.delete(arg); + }, + reject(arg: A, e: unknown) { + pending.get(arg)!.reject(e); + pending.delete(arg); + }, + inFlight() { + return [...pending.keys()]; + } + }; +} + +describe("createDeferred", () => { + describe("D5 — first load is loading, not pending", () => { + it("suspends readers to the nearest Loading boundary until the first landing", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + let slot: string | undefined; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(period())); + const view = createLoadingBoundary( + () => rows(), + () => "loading" + ); + createRenderEffect(view, v => { + slot = v; + }); + }); + flush(); + expect(slot).toBe("loading"); + // Uninitialized: loading-class, not pending. + fetcher.resolve(1); + await settle(); + expect(slot).toBe("rows-1"); + void setPeriod; + }); + }); + + describe("D1/D4 — an initialized node serves committed truth during a refetch and reports pending", () => { + it("readers keep the committed value, the render effect does not re-run, isPending is loud, the landing commits", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + const log: string[] = []; + const pendingLog: boolean[] = []; + let rows!: () => string; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(period())); + createRenderEffect(rows, v => { + log.push(v); + }); + createRenderEffect( + () => isPending(rows), + v => { + pendingLog.push(v); + } + ); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect(log).toEqual(["rows-1"]); + expect(pendingLog).toEqual([false]); + + setPeriod(2); + flush(); + // No suspension, no hold: the committed value keeps serving... + expect(rows()).toBe("rows-1"); + expect(log).toEqual(["rows-1"]); + // ...and the verdict is loud. + expect(isPending(rows)).toBe(true); + expect(pendingLog).toEqual([false, true]); + // The write to the input committed immediately — nobody held it. + expect(period()).toBe(2); + expect(isPending(period)).toBe(false); + + fetcher.resolve(2); + await settle(); + expect(rows()).toBe("rows-2"); + expect(log).toEqual(["rows-1", "rows-2"]); + expect(isPending(rows)).toBe(false); + expect(pendingLog).toEqual([false, true, false]); + }); + + it("the originating report: independent panels land on their own schedule", async () => { + const fast = deferredFetcher((n: number) => `fast-${n}`); + const slow = deferredFetcher((n: number) => `slow-${n}`); + const [period, setPeriod] = createSignal(1); + let fastSlot: string | undefined, slowSlot: string | undefined, label: number | undefined; + createRoot(() => { + const fastRows = createDeferred(() => fast.fetch(period())); + const slowRows = createDeferred(() => slow.fetch(period())); + createRenderEffect(period, v => { + label = v; + }); + createRenderEffect(fastRows, v => { + fastSlot = v; + }); + createRenderEffect(slowRows, v => { + slowSlot = v; + }); + }); + flush(); + fast.resolve(1); + slow.resolve(1); + await settle(); + expect([label, fastSlot, slowSlot]).toEqual([1, "fast-1", "slow-1"]); + + setPeriod(2); + flush(); + // The selector flips at once; both panels show their previous answer. + expect([label, fastSlot, slowSlot]).toEqual([2, "fast-1", "slow-1"]); + + fast.resolve(2); + await settle(); + // The fast panel is not hostage to the slow one. + expect([label, fastSlot, slowSlot]).toEqual([2, "fast-2", "slow-1"]); + + slow.resolve(2); + await settle(); + expect([label, fastSlot, slowSlot]).toEqual([2, "fast-2", "slow-2"]); + }); + }); + + describe("D3 — a landing during an unrelated incomplete transition commits", () => { + it("the deferred consumer repaints while the theme transition is still held", async () => { + const images = deferredFetcher((t: string) => `img-${t}`); + const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`); + const [theme, setTheme] = createSignal("light"); + const [period, setPeriod] = createSignal(1); + let themeSlot: string | undefined, imgSlot: string | undefined, rowsSlot: string | undefined; + createRoot(() => { + // A plain async memo derived from theme: its render effect reports, + // so a theme change opens a transition that waits for the image. + const themedImage = createMemo(() => images.fetch(theme())); + const rows = createDeferred(() => rowsFetcher.fetch(period())); + createRenderEffect(theme, v => { + themeSlot = v; + }); + createRenderEffect(themedImage, v => { + imgSlot = v; + }); + createRenderEffect(rows, v => { + rowsSlot = v; + }); + }); + flush(); + images.resolve("light"); + rowsFetcher.resolve(1); + await settle(); + expect([themeSlot, imgSlot, rowsSlot]).toEqual(["light", "img-light", "rows-1"]); + + // Start the deferred refetch first, against committed inputs. + setPeriod(2); + flush(); + expect(rowsSlot).toBe("rows-1"); + + // Now the theme switch: held on the image. + setTheme("dark"); + flush(); + expect([themeSlot, imgSlot]).toEqual(["light", "img-light"]); + expect(isPending(theme)).toBe(true); + + // The panel's landing arrives while the theme is held: it commits. + rowsFetcher.resolve(2); + await settle(); + expect(rowsSlot).toBe("rows-2"); + expect([themeSlot, imgSlot]).toEqual(["light", "img-light"]); + expect(isPending(theme)).toBe(true); + + images.resolve("dark"); + await settle(); + expect([themeSlot, imgSlot, rowsSlot]).toEqual(["dark", "img-dark", "rows-2"]); + }); + }); + + describe("D2 — never leads", () => { + it("a flight asked against a write a sibling holds reveals with that write, never ahead of it", async () => { + const summaryFetcher = deferredFetcher((n: number) => `summary-${n}`); + const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + let label: number | undefined, summarySlot: string | undefined, rowsSlot: string | undefined; + createRoot(() => { + // The plain sibling holds the period write; the deferred panel does not. + const summary = createMemo(() => summaryFetcher.fetch(period())); + const rows = createDeferred(() => rowsFetcher.fetch(period())); + createRenderEffect(period, v => { + label = v; + }); + createRenderEffect(summary, v => { + summarySlot = v; + }); + createRenderEffect(rows, v => { + rowsSlot = v; + }); + }); + flush(); + summaryFetcher.resolve(1); + rowsFetcher.resolve(1); + await settle(); + expect([label, summarySlot, rowsSlot]).toEqual([1, "summary-1", "rows-1"]); + + setPeriod(2); + flush(); + // The sibling holds the write. The deferred panel serves stale and does + // not add to what the transition waits for. + expect([label, summarySlot, rowsSlot]).toEqual([1, "summary-1", "rows-1"]); + expect(isPending(period)).toBe(true); + + // The deferred flight lands FIRST. Its answer is for period 2, which is + // still held — showing it beside label 1 would lead the clock. + rowsFetcher.resolve(2); + await settle(); + expect([label, summarySlot, rowsSlot]).toEqual([1, "summary-1", "rows-1"]); + + // The sibling lands: everything reveals together. + summaryFetcher.resolve(2); + await settle(); + expect([label, summarySlot, rowsSlot]).toEqual([2, "summary-2", "rows-2"]); + }); + + it("the transition does not wait for the deferred flight: the sibling landing reveals the write, the panel follows", async () => { + const summaryFetcher = deferredFetcher((n: number) => `summary-${n}`); + const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + let label: number | undefined, summarySlot: string | undefined, rowsSlot: string | undefined; + let rows!: () => string; + createRoot(() => { + const summary = createMemo(() => summaryFetcher.fetch(period())); + rows = createDeferred(() => rowsFetcher.fetch(period())); + createRenderEffect(period, v => { + label = v; + }); + createRenderEffect(summary, v => { + summarySlot = v; + }); + createRenderEffect(rows, v => { + rowsSlot = v; + }); + }); + flush(); + summaryFetcher.resolve(1); + rowsFetcher.resolve(1); + await settle(); + + setPeriod(2); + flush(); + // Sibling lands while the deferred panel is still in flight. + summaryFetcher.resolve(2); + await settle(); + expect([label, summarySlot, rowsSlot]).toEqual([2, "summary-2", "rows-1"]); + expect(isPending(rows)).toBe(true); + + rowsFetcher.resolve(2); + await settle(); + expect([label, summarySlot, rowsSlot]).toEqual([2, "summary-2", "rows-2"]); + expect(isPending(rows)).toBe(false); + }); + }); + + describe("create-deferred.md 8.1 — isPending through derivations of a deferred node", () => { + it("a sync memo over the deferred node reads pending during the refetch without re-running", async () => { + const fetcher = deferredFetcher((n: number) => Array.from({ length: n }, (_, i) => i)); + const [n, setN] = createSignal(2); + let count!: () => number; + let runs = 0; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(n())); + count = createMemo(() => (runs++, rows().length)); + }); + flush(); + fetcher.resolve(2); + await settle(); + expect(count()).toBe(2); + expect(isPending(count)).toBe(false); + const runsAfterLanding = runs; + + setN(3); + flush(); + expect(count()).toBe(2); + expect(runs).toBe(runsAfterLanding); + expect(isPending(count)).toBe(true); + + fetcher.resolve(3); + await settle(); + expect(count()).toBe(3); + expect(isPending(count)).toBe(false); + }); + + it("a derived store over the deferred node reads pending at its leaves", async () => { + const fetcher = deferredFetcher((n: number) => ({ + items: Array.from({ length: n }, (_, i) => i) + })); + const [n, setN] = createSignal(2); + let store!: { items: number[] }; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(n())); + [store] = createStore(() => rows(), { items: [] as number[] }); + }); + flush(); + fetcher.resolve(2); + await settle(); + expect(store.items.length).toBe(2); + expect(isPending(() => store.items.length)).toBe(false); + + setN(3); + flush(); + expect(store.items.length).toBe(2); + expect(isPending(() => store.items.length)).toBe(true); + + fetcher.resolve(3); + await settle(); + expect(store.items.length).toBe(3); + expect(isPending(() => store.items.length)).toBe(false); + }); + + it("a landing equal to the committed value still clears the verdict downstream", async () => { + const fetcher = deferredFetcher((n: number) => "same"); + const [n, setN] = createSignal(1); + let upper!: () => string; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(n())); + upper = createMemo(() => rows().toUpperCase()); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect(upper()).toBe("SAME"); + + setN(2); + flush(); + expect(isPending(upper)).toBe(true); + fetcher.resolve(2); + await settle(); + expect(upper()).toBe("SAME"); + expect(isPending(upper)).toBe(false); + }); + }); + + describe("D9 — authoritative readers bypass the clamp", () => { + it("await refresh(d) resolves with the landed value, not the served stale one", async () => { + let calls = 0; + const fetcher = deferredFetcher((_: number) => `answer-${++calls}`); + let rows!: SourceAccessor; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(0)); + }); + flush(); + fetcher.resolve(0); + await settle(); + expect(rows()).toBe("answer-1"); + + let resolved: string | undefined; + const p = refresh(rows).then(v => (resolved = v)); + await settle(); + // The re-ask is in flight: ordinary readers still see the stale answer, + // and the waiter has NOT resolved with it. + expect(rows()).toBe("answer-1"); + expect(resolved).toBeUndefined(); + + fetcher.resolve(0); + await settle(); + await p; + expect(resolved).toBe("answer-2"); + expect(rows()).toBe("answer-2"); + }); + + it("until(() => d()) evaluates only settled truth", async () => { + const fetcher = deferredFetcher((n: number) => n); + const [n, setN] = createSignal(1); + let rows!: () => number; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(n())); + }); + flush(); + fetcher.resolve(1); + await settle(); + + setN(5); + flush(); + let result: number | undefined; + const p = until(() => rows() >= 5).then(() => (result = rows())); + await settle(); + // Stale truth (1) must not be evaluated as the answer; the predicate parks. + expect(result).toBeUndefined(); + fetcher.resolve(5); + await settle(); + await p; + expect(result).toBe(5); + }); + + // The flight is asked against the action's own staged write, so it is + // stamped with the action's transaction and its landing is held by it + // (D2). The transaction waits on the action, the action on until(): the + // predicate must accept the staged answer or the three deadlock. + for (const form of ["direct", "derived"] as const) { + it(`until(() => ${form === "direct" ? "d()" : "derived()"}) yielded from the action whose write asked the flight settles on the held answer`, async () => { + const fetcher = deferredFetcher((n: number) => n); + const [n, setN] = createSignal(1); + let rows!: () => number; + let read!: () => number; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(n())); + read = form === "direct" ? rows : createMemo(() => rows() * 10); + }); + flush(); + fetcher.resolve(1); + await settle(); + const target = form === "direct" ? 5 : 50; + + let seen: number | undefined; + let done = false; + const act = action(function* () { + setN(5); + // Truthy on the stale value too: only parking on the flight makes + // the delivered value the landed one. + seen = yield until(() => read()); + }); + act().then(() => (done = true)); + await settle(); + // The predicate parks on the unanswered flight; the stale value is + // not delivered as the answer, and the readers are still served it. + expect(seen).toBeUndefined(); + expect(done).toBe(false); + expect(read()).toBe(form === "direct" ? 1 : 10); + + fetcher.resolve(5); + await settle(); + expect(seen).toBe(target); + expect(done).toBe(true); + expect(n()).toBe(5); + expect(read()).toBe(target); + }); + } + + it("refresh(d) is a quiet re-ask: isPending stays false while the waiter still parks (A24)", async () => { + let calls = 0; + const fetcher = deferredFetcher((_: number) => `answer-${++calls}`); + let rows!: SourceAccessor; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(0)); + }); + flush(); + fetcher.resolve(0); + await settle(); + + const p = refresh(rows); + await settle(); + expect(isPending(rows)).toBe(false); + fetcher.resolve(0); + await settle(); + expect(await p).toBe("answer-2"); + }); + }); + + describe("D6 — errors propagate", () => { + it("a rejected refetch throws to the error boundary; the stale value does not mask it", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + let slot: string | undefined; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(n())); + const view = createErrorBoundary( + () => rows(), + e => `error:${(e() as Error).message}` + ); + createRenderEffect(view, v => { + slot = v; + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect(slot).toBe("rows-1"); + + setN(2); + flush(); + expect(slot).toBe("rows-1"); + fetcher.reject(2, new Error("boom")); + await settle(); + expect(slot).toBe("error:boom"); + }); + }); + + describe("create-deferred.md 2.1 — loadingValue composition", () => { + it("is born committed, verdict-quiet through the first flight, and loud on refetch", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + let rows!: () => string | undefined; + const log: (string | undefined)[] = []; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(n()), { loadingValue: undefined }); + createRenderEffect(rows, v => { + log.push(v); + }); + }); + flush(); + expect(log).toEqual([undefined]); + expect(isPending(rows)).toBe(false); + + fetcher.resolve(1); + await settle(); + expect(log).toEqual([undefined, "rows-1"]); + + setN(2); + flush(); + expect(rows()).toBe("rows-1"); + expect(isPending(rows)).toBe(true); + fetcher.resolve(2); + await settle(); + expect(log).toEqual([undefined, "rows-1", "rows-2"]); + expect(isPending(rows)).toBe(false); + }); + }); + + describe("D8 — the wrapping form", () => { + it("createDeferred(() => held()) serves stale while the held memo's other consumers suspend", async () => { + const fetcher = deferredFetcher((n: number) => `q-${n}`); + const [n, setN] = createSignal(1); + let heldSlot: string | undefined, laggedSlot: string | undefined; + let lagged!: () => string; + createRoot(() => { + const query = createMemo(() => fetcher.fetch(n())); + lagged = createDeferred(() => query()); + createRenderEffect(query, v => { + heldSlot = v; + }); + createRenderEffect(lagged, v => { + laggedSlot = v; + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect([heldSlot, laggedSlot]).toEqual(["q-1", "q-1"]); + + setN(2); + flush(); + expect([heldSlot, laggedSlot]).toEqual(["q-1", "q-1"]); + expect(lagged()).toBe("q-1"); + expect(isPending(lagged)).toBe(true); + expect(isPending(n)).toBe(true); + + fetcher.resolve(2); + await settle(); + expect([heldSlot, laggedSlot]).toEqual(["q-2", "q-2"]); + expect(isPending(lagged)).toBe(false); + }); + }); + + describe("effects and disposal", () => { + it("a user effect over the deferred node runs once per landing, never for the flight", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + const log: string[] = []; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(n())); + createEffect(rows, v => { + log.push(v); + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + setN(2); + flush(); + await settle(); + expect(log).toEqual(["rows-1"]); + fetcher.resolve(2); + await settle(); + expect(log).toEqual(["rows-1", "rows-2"]); + }); + + it("a superseded flight's landing is dropped; the newest wins", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + let rows!: () => string; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(n())); + }); + flush(); + fetcher.resolve(1); + await settle(); + + setN(2); + flush(); + setN(3); + flush(); + expect(isPending(rows)).toBe(true); + fetcher.resolve(2); + await settle(); + expect(rows()).toBe("rows-1"); + expect(isPending(rows)).toBe(true); + fetcher.resolve(3); + await settle(); + expect(rows()).toBe("rows-3"); + expect(isPending(rows)).toBe(false); + }); + }); + + describe("create-deferred.md 8.2 — a landing during an in-flight optimistic action", () => { + it("a consumer of both keeps the override (A17) and never flashes the committed value; the other consumer repaints now", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + const [$toggled, setToggled] = createOptimistic(false); + const rowA: string[] = []; + const rowB: string[] = []; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(n())); + createRenderEffect( + () => `${rows()}:${$toggled()}`, + v => { + rowA.push(v); + } + ); + createRenderEffect(rows, v => { + rowB.push(v); + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect(rowA).toEqual(["rows-1:false"]); + expect(rowB).toEqual(["rows-1"]); + + // A refetch is asked, then the user acts optimistically while it flies. + setN(2); + flush(); + let finish!: () => void; + const act = action(function* () { + setToggled(true); + yield new Promise(r => (finish = r)); + }); + act(); + flush(); + expect(rowA).toEqual(["rows-1:false", "rows-1:true"]); + + // The landing arrives mid-action: an ambient commit, no lane. + fetcher.resolve(2); + await settle(); + // B is independent of the action: it repaints now. + expect(rowB).toEqual(["rows-1", "rows-2"]); + // A repaints now too, and keeps the override: the landing is a plain + // ambient commit, exactly what a signal write mid-action is — the + // committed (un-toggled) value never shows while the action runs. + expect(rowA).toEqual(["rows-1:false", "rows-1:true", "rows-2:true"]); + + finish(); + await settle(); + // The action settles: the override drops over the landed data. (The + // settle's run count is the lane engine's, identical for a plain signal + // written mid-action; only the values are pinned here.) + expect(rowA.slice(3).every(v => v === "rows-2:false")).toBe(true); + expect(rowA.length).toBeGreaterThan(3); + }); + }); + + // ── L2 pins (the hold model) ──────────────────────────────────────────── + + describe("L2 — Loading boundary: owns the first load, never sees a refetch (create-deferred.md 5)", () => { + it("content stays through a refetch — the boundary shows no fallback, isPending is the affordance", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + const log: string[] = []; + let rows!: () => string; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(period())); + const view = createLoadingBoundary( + () => rows(), + () => "loading" + ); + createRenderEffect(view, v => { + log.push(v); + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect(log).toEqual(["loading", "rows-1"]); + + setPeriod(2); + flush(); + // No fallback: the node never went pending, so the boundary has + // nothing to catch (A33 — a boundary collects pending readers; there + // are none). The write committed. + expect(log).toEqual(["loading", "rows-1"]); + expect(period()).toBe(2); + expect(isPending(rows)).toBe(true); + + fetcher.resolve(2); + await settle(); + expect(log).toEqual(["loading", "rows-1", "rows-2"]); + expect(isPending(rows)).toBe(false); + }); + + it("an `on` re-arm is inert for a deferred source: the boundary arms, nothing pends, the content stays", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + const log: string[] = []; + createRoot(() => { + const rows = createDeferred(() => fetcher.fetch(period())); + const view = createLoadingBoundary( + () => rows(), + () => "loading", + { on: () => period() } + ); + createRenderEffect(view, v => { + log.push(v); + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + expect(log).toEqual(["loading", "rows-1"]); + + setPeriod(2); + flush(); + // The re-arm would flip to the fallback at the first pending reader + // under it; a deferred refetch pends nobody (create-deferred.md 5: "a deferred node's + // refetch never arrives"). + expect(log).toEqual(["loading", "rows-1"]); + fetcher.resolve(2); + await settle(); + expect(log).toEqual(["loading", "rows-1", "rows-2"]); + }); + }); + + describe("L2 — inside an action: the flight is the action's work, never ahead of it", () => { + it("a flight asked by the action's write is held with the action; its landing reveals at the settle", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + let label: number | undefined, rowsSlot: string | undefined; + let rows!: () => string; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(period())); + createRenderEffect(period, v => { + label = v; + }); + createRenderEffect(rows, v => { + rowsSlot = v; + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + + let finish!: () => void; + const act = action(function* () { + setPeriod(2); + yield new Promise(r => (finish = r)); + }); + act(); + await settle(); + // The action holds the write; the panel serves stale and does not + // suspend the body (nothing holds the action but itself). + expect([label, rowsSlot]).toEqual([1, "rows-1"]); + expect(isPending(period)).toBe(true); + expect(isPending(rows)).toBe(true); + + // The landing arrives mid-action: asked against the held write, it is + // the action's (D2) — staged under its transaction, nothing moves. + fetcher.resolve(2); + await settle(); + expect([label, rowsSlot]).toEqual([1, "rows-1"]); + // Not final either way: the landed value is held uncommitted (A19 iii). + expect(isPending(rows)).toBe(true); + + finish(); + await settle(); + expect([label, rowsSlot]).toEqual([2, "rows-2"]); + expect(isPending(rows)).toBe(false); + expect(isPending(period)).toBe(false); + }); + + it("the action does not wait for the flight: it settles, the write reveals, the panel follows", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + let label: number | undefined, rowsSlot: string | undefined; + let rows!: () => string; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(period())); + createRenderEffect(period, v => { + label = v; + }); + createRenderEffect(rows, v => { + rowsSlot = v; + }); + }); + flush(); + fetcher.resolve(1); + await settle(); + + let finish!: () => void; + let done = false; + const act = action(function* () { + setPeriod(2); + yield new Promise(r => (finish = r)); + }); + act().then(() => (done = true)); + await settle(); + finish(); + await settle(); + // The body returned and nothing on screen derives from a flight the + // transaction holds (`blocked`: a deferred node is never pending), so + // it lands: the write reveals, the panel still shows its previous answer. + expect(done).toBe(true); + expect([label, rowsSlot]).toEqual([2, "rows-1"]); + expect(isPending(rows)).toBe(true); + + fetcher.resolve(2); + await settle(); + expect([label, rowsSlot]).toEqual([2, "rows-2"]); + expect(isPending(rows)).toBe(false); + }); + }); + + describe("L2 — the verdict is display-ahead (ruling 6): a tracked isPending flips while the flush parks", () => { + it("a render effect on isPending(rows) shows true at once, though a sibling's flight holds the write", async () => { + const summaryFetcher = deferredFetcher((n: number) => `summary-${n}`); + const rowsFetcher = deferredFetcher((n: number) => `rows-${n}`); + const [period, setPeriod] = createSignal(1); + const pendingLog: boolean[] = []; + let summarySlot: string | undefined; + createRoot(() => { + const summary = createMemo(() => summaryFetcher.fetch(period())); + const rows = createDeferred(() => rowsFetcher.fetch(period())); + createRenderEffect(summary, v => { + summarySlot = v; + }); + createRenderEffect( + () => isPending(rows), + v => { + pendingLog.push(v); + } + ); + }); + flush(); + summaryFetcher.resolve(1); + rowsFetcher.resolve(1); + await settle(); + expect(pendingLog).toEqual([false]); + + setPeriod(2); + flush(); + // The flush parked on `summary`; the verdict reader is the holder's + // verdict lane's — shown now, not stashed with the frame. + expect(summarySlot).toBe("summary-1"); + expect(pendingLog).toEqual([false, true]); + + // The panel's landing joins the hold (D2); the verdict stays true + // through it — the landed value is held uncommitted (A19 iii), and the + // reader re-derives at the held landing exactly as a plain memo's + // does (a render effect has no equality gate; the run count is the + // engine's, the values are pinned here)... + rowsFetcher.resolve(2); + await settle(); + expect(pendingLog.every(v => v)).toBe(false); + expect(pendingLog.slice(1).every(v => v)).toBe(true); + // ...and flips at the reveal. + summaryFetcher.resolve(2); + await settle(); + expect(summarySlot).toBe("summary-2"); + expect(pendingLog.at(-1)).toBe(false); + expect(pendingLog.filter(v => !v)).toEqual([false, false]); + }); + }); + + describe("L2 — D9's push: the flight's close wakes the readers parked on it", () => { + it("until(() => derived()) over an unchanged derivation is released by an equal-value landing", async () => { + // The derivation never re-runs (the landing equals the committed + // value), so no value notification reaches the predicate; the flight's + // close re-runs it (affects-marks.ts `repoll`). + 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 and its mark at disposal", 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); + // The node's cleanup ends the flight: the mark drops with it, and the + // late landing is dropped by identity — a dead node freezes at its + // last committed value (#3024). + dispose(); + flush(); + expect(isPending(rows)).toBe(false); + fetcher.resolve(2); + await settle(); + expect(rows()).toBe("rows-1"); + expect(isPending(rows)).toBe(false); + }); + + it("latest(() => d()) is a no-op — nothing staged to lead with — and its own-async verdict is loud (A8)", async () => { + const fetcher = deferredFetcher((n: number) => `rows-${n}`); + const [n, setN] = createSignal(1); + let rows!: () => string; + createRoot(() => { + rows = createDeferred(() => fetcher.fetch(n())); + }); + flush(); + fetcher.resolve(1); + await settle(); + + setN(2); + flush(); + expect(latest(() => rows())).toBe("rows-1"); + expect(isPending(() => latest(() => rows()))).toBe(true); + fetcher.resolve(2); + await settle(); + expect(latest(() => rows())).toBe("rows-2"); + expect(isPending(() => latest(() => rows()))).toBe(false); + }); + }); + + /** mizulu's report on #3710 (playground `Lr5tYDWOTW2PD3JSHCdwbA`): a + * plain async memo `m1` over a deferred `d1` over a signal `count`, both + * read by a render effect under a Loading boundary, `count` also read by + * a render effect outside it (the button). "Click before m1:exec and the + * button goes freely; m1:exec shown but m1:done not yet, and every click + * in that period is blocked — the button does not increment until + * m1:end." Same on the PR and on L2. */ + describe("L2 — a plain async memo over a deferred node (mizulu's #3710 report)", () => { + function playground(downstream: typeof createMemo) { + const d1Fetch = deferredFetcher((c: number) => c * 2); + const m1Fetch = deferredFetcher((d: number) => d); + const [count, setCount] = createSignal(0); + let d1!: () => number; + let m1!: () => number; + const out = { view: "", button: -1 }; + createRoot(() => { + d1 = createDeferred(() => d1Fetch.fetch(count())); + m1 = downstream(() => m1Fetch.fetch(d1())); + const boundary = createLoadingBoundary( + () => `signal ${count()} deferred ${d1()} m1 ${m1()}`, + () => "loading..." + ); + createRenderEffect(boundary, v => { + out.view = v; + }); + createRenderEffect(count, v => { + out.button = v; + }); + }); + return { d1Fetch, m1Fetch, setCount, d1: () => d1(), m1: () => m1(), out }; + } + async function load(p: ReturnType) { + flush(); + expect(p.out.view).toBe("loading..."); + p.d1Fetch.resolve(0); + await settle(); + p.m1Fetch.resolve(0); + await settle(); + expect(p.out.view).toBe("signal 0 deferred 0 m1 0"); + expect(p.out.button).toBe(0); + } + + it("a write to the input never waits on the deferred flight (D1): the button and the boundary's sync read repaint, the deferred lags", async () => { + const p = playground(createMemo); + await load(p); + p.setCount(1); + flush(); + expect(p.out.button).toBe(1); + expect(p.out.view).toBe("signal 1 deferred 0 m1 0"); + expect(isPending(p.d1)).toBe(true); + p.setCount(2); + flush(); + expect(p.out.button).toBe(2); + expect(p.out.view).toBe("signal 2 deferred 0 m1 0"); + // A superseded landing is dropped; the live one re-runs the plain memo. + p.d1Fetch.resolve(1); + await settle(); + expect(p.out.view).toBe("signal 2 deferred 0 m1 0"); + p.d1Fetch.resolve(2); + await settle(); + // The landing re-ran `m1`, which went pending under the frame that + // reads it: an ordinary async memo's hold (A15), and the landing — an + // ordinary commit — is staged with it. Nothing deferred-specific + // holds here (D1: `d1` itself blocks no transaction). + expect(p.m1Fetch.inFlight()).toEqual([4]); + expect(p.out.view).toBe("signal 2 deferred 0 m1 0"); + expect(isPending(p.m1)).toBe(true); + p.m1Fetch.resolve(4); + await settle(); + expect(p.out.view).toBe("signal 2 deferred 4 m1 4"); + expect(p.out.button).toBe(2); + }); + + it("with the downstream memo deferred too, no click ever waits: both lag, the sync reads never do (D1, D3)", async () => { + const p = playground(createDeferred as typeof createMemo); + await load(p); + p.setCount(1); + flush(); + p.setCount(2); + flush(); + expect(p.out.button).toBe(2); + p.d1Fetch.resolve(1); + p.d1Fetch.resolve(2); + await settle(); + // The landing commits (D3) and `m1` re-asks without holding. + expect(p.out.view).toBe("signal 2 deferred 4 m1 0"); + expect(p.m1Fetch.inFlight()).toEqual([4]); + p.setCount(3); + flush(); + expect(p.out.button).toBe(3); + expect(p.out.view).toBe("signal 3 deferred 4 m1 0"); + p.setCount(4); + flush(); + expect(p.out.button).toBe(4); + expect(p.out.view).toBe("signal 4 deferred 4 m1 0"); + p.m1Fetch.resolve(4); + await settle(); + expect(p.out.view).toBe("signal 4 deferred 4 m1 4"); + for (const k of p.d1Fetch.inFlight()) p.d1Fetch.resolve(k); + await settle(); + expect(p.out.view).toBe("signal 4 deferred 8 m1 4"); + for (const k of p.m1Fetch.inFlight()) p.m1Fetch.resolve(k); + await settle(); + expect(p.out.view).toBe("signal 4 deferred 8 m1 8"); + expect(isPending(p.d1)).toBe(false); + expect(isPending(p.m1)).toBe(false); + }); + + // The common part (lands ahead of the ruling, correct under either + // reading): a re-pass over a held landing keeps it. `m1`'s flight holds + // the frame and `d1`'s landing (2) is staged with it; a mainline write + // to `count` re-passes `d1`. The wrapper's pass returns the staging as + // its result — `handleAsync` served the committed value, and returned + // as the result it was compared against the staging (INV-11) and + // restaged over the landing: `m1` re-fetched against the OLD input and + // the landing was lost until a later flight re-landed it. And the seam + // sweep re-opens the window a commit closed under a question it did + // not answer: when the hold lands, `commitPendingNode` closes `d1`'s + // window on the older landing while the click's flight is still in the + // air (D4). Pinned on the text-memo graph, where the tick is held + // either way (the memo reads `m1`, ruling 3). + describe("a re-pass over a held landing keeps it (the common part)", () => { + it("m1 keeps its one flight; the hold lands with the landing it staged; isPending(d1) stays true while the newer flight is live", async () => { + const p = playground(createMemo); + await load(p); + p.setCount(1); + flush(); + p.d1Fetch.resolve(1); + await settle(); + expect(p.out.view).toBe("signal 1 deferred 0 m1 0"); + expect(p.m1Fetch.inFlight()).toEqual([2]); + // Clicks while m1 is in flight re-pass d1 (held, staged 2). + p.setCount(2); + flush(); + p.setCount(3); + flush(); + // The held landing stands: m1 is not re-run against the old input; + // d1's own questions are the clicks'. + expect(p.m1Fetch.inFlight()).toEqual([2]); + expect(p.d1Fetch.inFlight()).toEqual([2, 3]); + expect(isPending(p.d1)).toBe(true); + p.m1Fetch.resolve(2); + await settle(); + // The hold lands with the landing it staged, never without it. + expect(p.out.view).toBe("signal 3 deferred 2 m1 2"); + expect(p.out.button).toBe(3); + // The commit of the older landing did not answer the newer question + // — the window re-opens at the seam, the mark stays (D4). + expect(isPending(p.d1)).toBe(true); + expect(isPending(p.m1)).toBe(true); + p.d1Fetch.resolve(2); // superseded: dropped by identity + await settle(); + expect(p.out.view).toBe("signal 3 deferred 2 m1 2"); + p.d1Fetch.resolve(3); + await settle(); + expect(p.m1Fetch.inFlight()).toEqual([6]); + p.m1Fetch.resolve(6); + await settle(); + expect(p.out.view).toBe("signal 3 deferred 6 m1 6"); + expect(isPending(p.d1)).toBe(false); + expect(isPending(p.m1)).toBe(false); + }); + }); + + /** The playground's real graph: the `{}` inserts under `` are + * render effects of their own; the boundary's compute reads none of the + * held nodes. (The text-memo `playground()` above puts a derivation + * between them and the frame — which joins any hold it reads, ruling 3.) */ + function playgroundDom() { + const d1Fetch = deferredFetcher((c: number) => c * 2); + const m1Fetch = deferredFetcher((d: number) => d); + const [count, setCount] = createSignal(0); + let d1!: () => number; + let m1!: () => number; + const dom = { fallback: false, sig: -1, def: -1, m1: -1, button: -1 }; + createRoot(() => { + d1 = createDeferred(() => d1Fetch.fetch(count())); + m1 = createMemo(() => m1Fetch.fetch(d1())); + const boundary = createLoadingBoundary( + () => { + createRenderEffect(count, v => { + dom.sig = v; + }); + createRenderEffect(d1, v => { + dom.def = v; + }); + createRenderEffect(m1, v => { + dom.m1 = v; + }); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(boundary, v => { + dom.fallback = v === "fallback"; + }); + createRenderEffect(count, v => { + dom.button = v; + }); + }); + const view = () => + dom.fallback ? "fallback" : `signal ${dom.sig} deferred ${dom.def} m1 ${dom.m1}`; + return { d1Fetch, m1Fetch, setCount, d1: () => d1(), m1: () => m1(), dom, view }; + } + + // Ruled 2026-10-04 (D11 — a lagging question is not an answer). Once `m1`'s + // flight holds the frame, `d1` is a held node (its landing is staged in + // that hold). A mainline write to `count` re-passes it; the pass joins + // nothing (`recompute`'s T4 arm skips a CONFIG_LAGS node) and keeps + // the held landing (the wrapper returns the staging as its result), so + // the write commits mainline: the button and the boundary's sync insert + // repaint, the inserts of `d1` and `m1` are stale readers re-derived at + // `m1`'s landing (A15), and the seam sweep keeps `d1`'s window open + // under the question the commit of the older landing did not answer + // (D4). + describe("a write to the input while the downstream plain memo is in flight commits mainline; the held landing stands (D11)", () => { + it("the button never waits; the frame's readers of the flight are stale readers; m1 lands with the input it was asked with", async () => { + const p = playgroundDom(); + flush(); + expect(p.view()).toBe("fallback"); + p.d1Fetch.resolve(0); + await settle(); + p.m1Fetch.resolve(0); + await settle(); + expect(p.view()).toBe("signal 0 deferred 0 m1 0"); + p.setCount(1); + flush(); + expect(p.view()).toBe("signal 1 deferred 0 m1 0"); + expect(p.dom.button).toBe(1); + p.d1Fetch.resolve(1); + await settle(); + // m1 re-ran on the landing and holds the frame; the landing is staged with it. + expect(p.view()).toBe("signal 1 deferred 0 m1 0"); + expect(p.m1Fetch.inFlight()).toEqual([2]); + expect(isPending(p.m1)).toBe(true); + // Clicks while m1 is in flight: mainline. The sync readers repaint; + // the readers of the flight keep the committed frame. + p.setCount(2); + flush(); + expect(p.dom.button).toBe(2); + expect(p.view()).toBe("signal 2 deferred 0 m1 0"); + p.setCount(3); + flush(); + expect(p.dom.button).toBe(3); + expect(p.view()).toBe("signal 3 deferred 0 m1 0"); + // The held landing (2) stands: m1 keeps its one flight; d1's own + // questions are the clicks'. + expect(p.m1Fetch.inFlight()).toEqual([2]); + expect(p.d1Fetch.inFlight()).toEqual([2, 3]); + expect(isPending(p.d1)).toBe(true); + p.m1Fetch.resolve(2); + await settle(); + // The hold lands with the landing it staged; the stale readers + // re-derive on it. + expect(p.view()).toBe("signal 3 deferred 2 m1 2"); + expect(p.dom.button).toBe(3); + // d1's newest question is still in the air (D4). + expect(isPending(p.d1)).toBe(true); + p.d1Fetch.resolve(2); + await settle(); + expect(p.view()).toBe("signal 3 deferred 2 m1 2"); + p.d1Fetch.resolve(3); + await settle(); + expect(p.m1Fetch.inFlight()).toEqual([6]); + p.m1Fetch.resolve(6); + await settle(); + expect(p.view()).toBe("signal 3 deferred 6 m1 6"); + expect(isPending(p.d1)).toBe(false); + expect(isPending(p.m1)).toBe(false); + }); + + // The limit of D11, L2-general and not deferred-specific: a derivation + // (here the boundary's text memo) re-run by the write reads the held + // pending `m1` and joins its hold (`read` → `joinPass`, ruling 3: a + // derivation of a held node published mainline would tear). The + // deferred node is not involved — the pure-L2 control below, with no + // deferred node in the graph, holds the same way. + it("with a derivation between the held memo and the frame, the write still joins — the derivation reads m1 (ruling 3); nothing deferred-specific", async () => { + const p = playground(createMemo); + await load(p); + p.setCount(1); + flush(); + p.d1Fetch.resolve(1); + await settle(); + expect(p.m1Fetch.inFlight()).toEqual([2]); + p.setCount(2); + flush(); + // The boundary's text memo re-passes, reads the held pending `m1`, + // and joins its hold: the tick parks, the button with it — as it + // would with no deferred node in the graph at all. + expect(p.out.button).toBe(1); + expect(p.out.view).toBe("signal 1 deferred 0 m1 0"); + // The held landing stands all the same (the common part). + expect(p.m1Fetch.inFlight()).toEqual([2]); + p.m1Fetch.resolve(2); + await settle(); + expect(p.out.view).toBe("signal 2 deferred 2 m1 2"); + expect(p.out.button).toBe(2); + p.d1Fetch.resolve(2); + await settle(); + p.m1Fetch.resolve(4); + await settle(); + expect(p.out.view).toBe("signal 2 deferred 4 m1 4"); + }); + + it("pure-L2 control: no deferred node — a derivation reading a held pending memo joins the hold on an unrelated write", async () => { + const fetcher = deferredFetcher((n: number) => n * 10); + const [a, setA] = createSignal(0); + const [b, setB] = createSignal(0); + const out = { view: "", button: -1 }; + let m!: () => number; + createRoot(() => { + m = createMemo(() => fetcher.fetch(a())); + const boundary = createLoadingBoundary( + () => `b ${b()} m ${m()}`, + () => "loading..." + ); + createRenderEffect(boundary, v => { + out.view = v; + }); + createRenderEffect(b, v => { + out.button = v; + }); + }); + flush(); + fetcher.resolve(0); + await settle(); + expect(out.view).toBe("b 0 m 0"); + setA(1); + flush(); + expect(isPending(m)).toBe(true); + // A write to `b`, unrelated to the flight: the text memo re-passes, + // reads the held pending `m`, joins — the button waits too. + setB(1); + flush(); + expect(out.button).toBe(0); + expect(out.view).toBe("b 0 m 0"); + fetcher.resolve(1); + await settle(); + expect(out.view).toBe("b 1 m 10"); + expect(out.button).toBe(1); + }); + }); + }); +}); diff --git a/packages/signals/tests/create-deferred.test.ts b/packages/signals/tests/create-deferred.test.ts new file mode 100644 index 0000000000..c5df496346 --- /dev/null +++ b/packages/signals/tests/create-deferred.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createDeferred, + createErrorBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + refresh, + createStore, + createLoadingBoundary, + until, + getOwner, + runWithOwner +} from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + flush(); +}; + +function gated() { + const gates: Array<() => void> = []; + const fails: Array<(e: any) => void> = []; + const wait = (v: T) => + new Promise((res, rej) => { + gates.push(() => res(v)); + fails.push(rej); + }); + return { gates, fails, wait }; +} + +describe("createDeferred (D-40)", () => { + it("panels: the write commits now, each panel lags to its own landing, isPending per panel", async () => { + const [period, setPeriod] = createSignal(1); + const fast = gated(); + const slow = gated(); + const log: string[] = []; + createRoot(() => { + const a = createDeferred(() => fast.wait(`a${period()}`)); + const b = createDeferred(() => slow.wait(`b${period()}`)); + createRenderEffect(period, v => void log.push(`label ${v}`)); + createRenderEffect(a, v => void log.push(`A ${v}`)); + createRenderEffect(b, v => void log.push(`B ${v}`)); + createRenderEffect( + () => [isPending(a), isPending(b)].join(), + v => void log.push(`pending ${v}`) + ); + }); + flush(); + fast.gates.shift()!(); + slow.gates.shift()!(); + await tick(); + expect(log.sort()).toEqual(["A a1", "B b1", "label 1", "pending false,false"]); + log.length = 0; + + setPeriod(2); + flush(); + expect(log.sort()).toEqual(["label 2", "pending true,true"]); + log.length = 0; + + fast.gates.shift()!(); + await tick(); + expect(log.sort()).toEqual(["A a2", "pending false,true"]); + log.length = 0; + + slow.gates.shift()!(); + await tick(); + expect(log.sort()).toEqual(["B b2", "pending false,false"]); + }); + + it("control: plain async memos hold the label until the slowest lands", async () => { + const [period, setPeriod] = createSignal(1); + const fast = gated(); + const slow = gated(); + const log: string[] = []; + createRoot(() => { + const a = createMemo(() => fast.wait(`a${period()}`)); + const b = createMemo(() => slow.wait(`b${period()}`)); + createRenderEffect(period, v => void log.push(`label ${v}`)); + createRenderEffect(a, v => void log.push(`A ${v}`)); + createRenderEffect(b, v => void log.push(`B ${v}`)); + }); + flush(); + fast.gates.shift()!(); + slow.gates.shift()!(); + await tick(); + log.length = 0; + setPeriod(2); + flush(); + fast.gates.shift()!(); + await tick(); + expect(log).toEqual([]); + slow.gates.shift()!(); + await tick(); + expect(log.sort()).toEqual(["A a2", "B b2", "label 2"]); + }); + + it("first load is loading: the reader waits for the first answer (no stale to serve)", async () => { + const g = gated(); + const log: string[] = []; + createRoot(() => { + const d = createDeferred(() => g.wait(1)); + createRenderEffect(d, v => void log.push(`d ${v}`)); + createRenderEffect( + () => isPending(d), + v => void log.push(`p ${v}`) + ); + }); + flush(); + expect(log).toEqual([]); + g.gates.shift()!(); + await tick(); + expect(log.sort()).toEqual(["d 1", "p false"]); + }); + + it("isPending reaches a sync derivation of a deferred node in flight", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + const rows = createDeferred(() => g.wait(Array.from({ length: n() }, (_, i) => i))); + const count = createMemo(() => rows().length); + createRenderEffect( + () => `${count()} ${isPending(count)}`, + v => void log.push(v) + ); + }); + flush(); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 false"]); + setN(3); + flush(); + expect(log).toEqual(["1 false", "1 true"]); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 false", "1 true", "3 false"]); + }); + + it("a rejected refetch errors (never masked by the stale value)", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + createErrorBoundary( + () => { + const d = createDeferred(() => g.wait(n())); + createRenderEffect(d, v => void log.push(`d ${v}`)); + return null; + }, + e => { + log.push(`err ${(e as any)().message}`); + return null; + } + ); + }); + flush(); + g.gates.shift()!(); + await tick(); + setN(2); + flush(); + g.fails.pop()!(new Error("boom")); + await tick(); + expect(log).toContain("err boom"); + }); + + it("refresh(d) is quiet and resolves with the landed value, not the stale one (D9)", async () => { + let calls = 0; + const g = gated(); + let d!: () => number; + const log: string[] = []; + createRoot(() => { + d = createDeferred(() => g.wait(++calls)); + createRenderEffect( + () => `${d()} ${isPending(d)}`, + v => void log.push(v) + ); + }); + flush(); + g.gates.shift()!(); + await tick(); + const p = refresh(d as any); + let got: unknown = "unresolved"; + p.then(v => (got = v)); + await tick(); + expect(log).toEqual(["1 false"]); + expect(got).toBe("unresolved"); + g.gates.shift()!(); + await tick(); + expect(got).toBe(2); + expect(log).toEqual(["1 false", "2 false"]); + }); + + it("a refetch that read a held write lands with that hold, never ahead of it", async () => { + const [period, setPeriod] = createSignal(1); + const g = gated(); + const held = gated(); + const log: string[] = []; + createRoot(() => { + const d = createDeferred(() => g.wait(`d${period()}`)); + const h = createMemo(() => held.wait(`h${period()}`)); + createRenderEffect(h, v => void log.push(`H ${v}`)); + createRenderEffect(d, v => void log.push(`D ${v}`)); + }); + flush(); + g.gates.shift()!(); + held.gates.shift()!(); + await tick(); + log.length = 0; + setPeriod(2); + flush(); + g.gates.shift()!(); + await tick(); + expect(log).toEqual([]); + held.gates.shift()!(); + await tick(); + expect(log.sort()).toEqual(["D d2", "H h2"]); + }); + + it("an unrelated hold does not keep a deferred landing back", async () => { + const [period, setPeriod] = createSignal(1); + const [theme, setTheme] = createSignal("light"); + const g = gated(); + const themed = gated(); + const log: string[] = []; + createRoot(() => { + const d = createDeferred(() => g.wait(`d${period()}`)); + const t = createMemo(() => themed.wait(theme())); + createRenderEffect(t, v => void log.push(`T ${v}`)); + createRenderEffect(d, v => void log.push(`D ${v}`)); + }); + flush(); + g.gates.shift()!(); + themed.gates.shift()!(); + await tick(); + log.length = 0; + setTheme("dark"); + flush(); + setPeriod(2); + flush(); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["D d2"]); + themed.gates.shift()!(); + await tick(); + expect(log).toEqual(["D d2", "T dark"]); + }); + + it("wrapping form: createDeferred(() => heldMemo()) lags while the memo's other readers hold", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + const q = createMemo(() => g.wait(n())); + const d = createDeferred(() => q()); + createRenderEffect( + () => `${d()} ${isPending(d)}`, + v => void log.push(`D ${v}`) + ); + createRenderEffect(n, v => void log.push(`N ${v}`)); + }); + flush(); + g.gates.shift()!(); + await tick(); + log.length = 0; + setN(2); + flush(); + expect(log).toEqual(["N 2", "D 1 true"]); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["N 2", "D 1 true", "D 2 false"]); + }); + + it("store composition: createStore(() => ({ items: rows() })) never re-runs mid-refetch; isPending reaches a leaf", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + const rows = createDeferred(() => g.wait(Array.from({ length: n() }, (_, i) => i))); + const [s] = createStore<{ items: number[] }>(() => ({ items: rows() }), { items: [] }); + createRenderEffect( + () => `${s.items.length} ${isPending(() => s.items.length)}`, + v => void log.push(v) + ); + }); + flush(); + g.gates.shift()!(); + await tick(); + log.length = 0; + setN(3); + flush(); + expect(log).toEqual(["1 true"]); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 true", "3 false"]); + }); + + it("under a Loading boundary: fallback on first load only, never on refetch", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + const d = createDeferred(() => g.wait(n())); + const out = createLoadingBoundary( + () => { + createRenderEffect(d, v => void log.push(`d ${v}`)); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(out, v => void log.push(`show ${v}`)); + }); + flush(); + expect(log).toEqual(["show fallback"]); + g.gates.shift()!(); + await tick(); + expect(log).toContain("d 1"); + expect(log[log.length - 1]).toBe("show content"); + log.length = 0; + setN(2); + flush(); + expect(log).toEqual([]); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["d 2"]); + }); + + it("until() over a derivation of a deferred node waits for the landing, not the stale value", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + let count!: () => number; + createRoot(() => { + const d = createDeferred(() => g.wait(n())); + count = createMemo(() => d() * 10); + createRenderEffect(count, () => {}); + }); + flush(); + g.gates.shift()!(); + await tick(); + setN(2); + flush(); + let got: unknown = "unresolved"; + until(() => count() >= 10 && count()).then(v => (got = v)); + await tick(); + expect(got).toBe("unresolved"); + g.gates.shift()!(); + await tick(); + expect(got).toBe(20); + }); + + it("a stream's flight ends at its first yield", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + const d = createDeferred(async function* () { + const v = n(); + yield await g.wait(v); + yield await g.wait(v * 100); + }); + createRenderEffect( + () => `${d()} ${isPending(d)}`, + v => void log.push(v) + ); + }); + flush(); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 false"]); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 false", "100 false"]); + setN(2); + flush(); + expect(log).toEqual(["1 false", "100 false", "100 true"]); + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 false", "100 false", "100 true", "2 false"]); + }); + + it("disposing the node mid-flight drops its verdict", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + let dispose!: () => void; + let d!: () => number; + createRoot(() => { + createRoot(dd => { + dispose = dd; + d = createDeferred(() => g.wait(n())); + createRenderEffect(d, () => {}); + }); + }); + flush(); + g.gates.shift()!(); + await tick(); + setN(2); + flush(); + expect(isPending(d)).toBe(true); + dispose(); + flush(); + expect(isPending(d)).toBe(false); + }); + + it("with loadingValue it never suspends: the sentinel first, quiet; then lag", async () => { + const [n, setN] = createSignal(1); + const g = gated(); + const log: string[] = []; + createRoot(() => { + const d = createDeferred(() => g.wait(n()), { loadingValue: undefined }); + createRenderEffect( + () => `${d()} ${isPending(d)}`, + v => void log.push(v) + ); + createRenderEffect(n, v => void log.push(`N ${v}`)); + }); + flush(); + expect(log.sort()).toEqual(["N 1", "undefined false"]); + log.length = 0; + g.gates.shift()!(); + await tick(); + expect(log).toEqual(["1 false"]); + setN(2); + flush(); + expect(log.sort()).toEqual(["1 false", "1 true", "N 2"]); + g.gates.shift()!(); + await tick(); + expect(log[log.length - 1]).toBe("2 false"); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 26af62029f..9b7780b324 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -80,6 +80,8 @@ describe("pay-for-use tree-shaking (#2883)", () => { "boundaries.ts", "map.ts", "affects.ts", + "affects-marks.ts", + "deferred.ts", "core/verdict.ts", "core/lanes.ts", "core/action.ts", @@ -508,6 +510,37 @@ 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 core, and nothing else new", async () => { + const { retained } = await bundleFixture( + `export { createSignal, createEffect, createRoot, flush, createDeferred } from "sigsrc";` + ); + // The verdict is a mark on the node (affects-marks.ts, the core of the + // `affects()` channel), so the mark core comes with it; `affects()` + // itself and the verdict layer that reads the mark do not. + expect(retainedFrom(retained, ["deferred.ts", "affects-marks.ts"])).toEqual([ + "deferred.ts", + "affects-marks.ts" + ]); + expect( + retainedFrom(retained, [ + "store/", + "boundaries.ts", + "map.ts", + "affects.ts", + "core/verdict.ts", + "core/lanes.ts", + "core/action.ts" + ]) + ).toEqual([]); + }); + + it("refresh/until carry none of the deferred module: its lag check is a hook slot", async () => { + const { retained } = await bundleFixture( + `export { createSignal, createMemo, createEffect, createRoot, flush, refresh, until } from "sigsrc";` + ); + expect(retainedFrom(retained, ["deferred.ts", "affects-marks.ts", "affects.ts"])).toEqual([]); + }); + it("isPending/latest load the verdict layer and nothing else new", async () => { const { retained } = await bundleFixture( `export { createSignal, createEffect, createRoot, flush, isPending, latest } from "sigsrc";` diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index a42040a101..68d05df547 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -13,6 +13,7 @@ import { createMemo as coreMemo, createSignal as coreSignal, createOptimistic as coreOptimistic, + createDeferred as coreDeferred, createProjection as coreProjection, createStore as coreStore, createOptimisticStore as coreOptimisticStore, @@ -1767,6 +1768,29 @@ export const createOptimistic: { : (coreOptimistic as Function)(...args); }) as any; +/** + * Creates an async memo that may lag, but never leads: after its first + * answer, a refetch holds nothing — readers keep the previous answer until + * the new one lands, and `isPending` reports the refetch. See + * `createDeferred` in `@solidjs/signals`. Hydrates like `createMemo`: the + * server's answer is its first. + */ +export const createDeferred: { + ( + compute: ComputeFunction, T>, + options: HydrationMemoOptions & { loadingValue: T } + ): SourceAccessor; + ( + compute: ComputeFunction, T>, + options?: HydrationMemoOptions + ): SourceAccessor; +} = ((fn: any, options?: any) => + // As createOptimistic: the adapter slot keeps the deferred module out of + // hydrating bundles that never import this primitive. + sharedConfig.hydrating + ? _hydrateSignalLike!(coreDeferred, fn, options) + : coreDeferred(fn, options)) as any; + /** * Creates a derived (projected) store — `createMemo` for stores. The * derive function receives a mutable draft and either mutates it in diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index c07307961f..d2f450304a 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -102,6 +102,7 @@ export { enableHydration, createRoot, createMemo, + createDeferred, createSignal, createStore, createProjection, @@ -303,7 +304,6 @@ export { batch, // flush catchError, // old version handled by createErrorBoundary. new version is different helper. createComputed, // nope - createDeferred, // take it outside createResource, // all computations createSelector, // createProjection DevHooks, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index d24f6cb026..e1dde9a7b3 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -10,6 +10,7 @@ export { affects, createEffect, createMemo, + createDeferred, createOptimistic, createOptimisticStore, createOwner, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index d5e09d71f3..b65115d070 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -4161,6 +4161,10 @@ export function refresh( } } +/** On the server everything is a first load and the server always waits: + * `createDeferred` is `createMemo` (D-40). */ +export const createDeferred: typeof createMemo = createMemo; + export function affects(_target: unknown, _key?: PropertyKey): void { return undefined; } diff --git a/packages/solid/test/create-deferred-hydration.spec.ts b/packages/solid/test/create-deferred-hydration.spec.ts new file mode 100644 index 0000000000..605d23bd30 --- /dev/null +++ b/packages/solid/test/create-deferred-hydration.spec.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, test } from "vitest"; +import { createRoot, flush, isPending } from "@solidjs/signals"; +import { + enableHydration, + sharedConfig, + createDeferred, + createSignal, + createRenderEffect +} from "../src/client/hydration.js"; + +enableHydration(); + +function startHydration(data: Record) { + sharedConfig.hydrating = true; + (sharedConfig as any).has = (id: string) => id in data; + (sharedConfig as any).load = (id: string) => data[id]; + (sharedConfig as any).gather = () => {}; +} +function stopHydration() { + sharedConfig.hydrating = false; + (sharedConfig as any).has = undefined; + (sharedConfig as any).load = undefined; + (sharedConfig as any).gather = undefined; +} +const tick = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + flush(); +}; + +describe("createDeferred hydration (D-40)", () => { + afterEach(stopHydration); + + test("the server's answer is its first: the first client refetch lags it", async () => { + startHydration({ t0: 42 }); + let setN!: (v: number) => void; + let d!: () => number; + const fetches: number[] = []; + const gates: Array<() => void> = []; + const log: string[] = []; + createRoot( + () => { + const [n, set] = createSignal(1); + setN = set; + d = createDeferred(() => { + const v = n(); + fetches.push(v); + return new Promise(r => gates.push(() => r(v * 100))); + }); + createRenderEffect( + () => `${d()} ${isPending(d)}`, + v => void log.push(v) + ); + }, + { id: "t" } + ); + flush(); + stopHydration(); + await tick(); + expect(log).toEqual(["42 false"]); + + setN(2); + flush(); + expect(fetches[fetches.length - 1]).toBe(2); + expect(log).toEqual(["42 false", "42 true"]); + gates.pop()!(); + await tick(); + expect(log).toEqual(["42 false", "42 true", "200 false"]); + }); +}); diff --git a/packages/solid/test/server/signals.spec.ts b/packages/solid/test/server/signals.spec.ts index 5962ffff88..9cc5e210d7 100644 --- a/packages/solid/test/server/signals.spec.ts +++ b/packages/solid/test/server/signals.spec.ts @@ -4,6 +4,7 @@ import { createRoot, createSignal, createMemo, + createDeferred, createEffect, createRenderEffect, createTrackedEffect, @@ -172,6 +173,12 @@ describe("Server createMemo", () => { }); // === createEffect === +describe("Server createDeferred", () => { + test("is createMemo: the server always waits (D-40)", () => { + expect(createDeferred).toBe(createMemo); + }); +}); + describe("Server createEffect", () => { test("runs compute but not effectFn on the server", () => { const compute = vi.fn(() => 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(); +}); diff --git a/packages/web/test/harness/__artifacts__/deferred-settled-element.json b/packages/web/test/harness/__artifacts__/deferred-settled-element.json new file mode 100644 index 0000000000..b89b824ecf --- /dev/null +++ b/packages/web/test/harness/__artifacts__/deferred-settled-element.json @@ -0,0 +1,5 @@ +{ + "name": "deferred-settled-element", + "shell": "

loading

", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index 44de282f19..a02d62298d 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -24,6 +24,7 @@ import { createSignal, createMemo, + createDeferred, createEffect, onSettled, createProjection, @@ -266,6 +267,32 @@ function AsyncSettledDiv() { ); } +// --------------------------------------------------------------------------- +// 10a. createDeferred under Loading (docs/create-deferred.md D5/D7): the +// server renders it as the memo it wraps (one pass, no previous answer), so +// the chunk shape and the client's claim are scenario 10's; the serialized +// value is the client's commit #0. The post-hydration update is the one +// thing that differs: the refetch is CLAMPED — the claimed DOM keeps "Value: +// 42" while the flight is up (the update signal committed; nothing held it) +// and swaps to 43 at the landing. The harness sees the landing; the lag is +// pinned by test/hydration/create-deferred-lag.spec.tsx over this scenario's +// artifact. +let refreshDeferredDiv!: () => void; +function DeferredSettledDiv() { + const [version, setVersion] = createSignal(0); + refreshDeferredDiv = () => setVersion(v => v + 1); + const data = createDeferred(async () => { + const v = version(); + await sleep(5); + return 42 + v; + }); + return ( + loading

}> +
Value: {data()}
+
+ ); +} + // --------------------------------------------------------------------------- // 10b. Async memo under Loading that settles BEFORE the shell flush. Every // other async scenario sleeps, so the boundary always takes the streaming @@ -2399,6 +2426,15 @@ export const scenarios: Scenario[] = [ expectedTextAfterUpdate: "Value: 43", stableSelector: "div" }, + { + name: "deferred-settled-element", + App: DeferredSettledDiv, + async: true, + expectedText: "Value: 42", + update: () => refreshDeferredDiv(), + expectedTextAfterUpdate: "Value: 43", + stableSelector: "div" + }, { name: "async-inline-settled", App: AsyncInlineSettled, diff --git a/packages/web/test/hydration/create-deferred-lag.spec.tsx b/packages/web/test/hydration/create-deferred-lag.spec.tsx new file mode 100644 index 0000000000..d10991e9e1 --- /dev/null +++ b/packages/web/test/hydration/create-deferred-lag.spec.tsx @@ -0,0 +1,87 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * `createDeferred` through SSR and hydration (docs/create-deferred.md D5, + * D7): the server renders it as the memo it wraps, the client claims the + * server-rendered content as commit #0 (the parity harness pins the claim + * over the same artifact — scenario "deferred-settled-element"), and the + * first refetch after hydration is already clamped: the claimed DOM keeps the + * adopted answer while the flight is up, and swaps at the landing. The + * `createMemo` twin (scenario "async-settled-element") would hold the + * update and show the new value only at the landing too — what differs is + * invisible to a textContent check at the end, so this spec reads the DOM + * MID-flight. + * + * Replays the chunk artifact test/server/hydration-harness.spec.tsx writes + * (loaded mode: the full-page refresh case). + */ +import { describe, expect, test, vi } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { scenarios } from "../harness/scenarios.jsx"; + +const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function loadArtifact(name: string): { shell: string; rest: string } { + const file = resolve(artifactsDir, `${name}.json`); + if (!existsSync(file)) { + throw new Error( + `Missing artifact for scenario "${name}". Run the server harness first: ` + + `vitest run --config vite.config.server.mjs test/server/hydration-harness.spec.tsx` + ); + } + return JSON.parse(readFileSync(file, "utf-8")); +} + +function applyChunk(container: HTMLDivElement, chunk: string, first: boolean) { + const scriptRe = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(scriptRe)].map(m => m[1]); + const stripped = chunk.replace(scriptRe, ""); + if (first) container.innerHTML = stripped; + else container.insertAdjacentHTML("beforeend", stripped); + for (const s of scripts) (0, eval)(s); +} + +describe("createDeferred hydrates like createMemo and lags on its first refetch", () => { + test("the adopted answer stays on screen while the refetch flies; the landing swaps it in place", async () => { + const scenario = scenarios.find(s => s.name === "deferred-settled-element")!; + const { shell, rest } = loadArtifact(scenario.name); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let dispose: (() => void) | undefined; + try { + applyChunk(container, shell, true); + if (rest) applyChunk(container, rest, false); + dispose = hydrate(() => , container); + flush(); + await sleep(10); + flush(); + const div = container.querySelector("div")!; + expect(div.textContent).toBe("Value: 42"); + + // The refetch: the write commits (nothing holds it), the claimed node + // keeps serving the adopted answer, no fallback. + scenario.update!(); + flush(); + expect(container.textContent).toBe("Value: 42"); + expect(container.querySelector("p")).toBeNull(); + + await sleep(50); + flush(); + expect(container.textContent).toBe("Value: 43"); + expect(container.querySelector("div"), "the claimed element survives the landing").toBe(div); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + dispose?.(); + container.remove(); + } + }); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index b6577b6698..b685d4b84e 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -1,15 +1,15 @@ { "signals: core floor (createSignal/Memo/Effect/Root/flush)": { - "cap": "7.48 KB", - "minified": 20436 + "cap": "7.50 KB", + "minified": 20469 }, "app: render + one signal (the simple-app floor)": { - "cap": "9.98 KB", - "minified": 27945 + "cap": "10.01 KB", + "minified": 27977 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { - "cap": "18.09 KB", - "minified": 53350 + "cap": "18.11 KB", + "minified": 53386 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { "cap": "34.334 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 1bc5d420f3..4322fa7d43 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -576,6 +576,14 @@ module.exports = [ // instead of the committed screen. Cap set at measured + 10 B rounded up to // 0.01 KB. Accepted by the maintainer 2026-10-13; hello world stays under // 10 KB at 9,966 B. The cap is frozen again at 7.48 KB. + // Size-Exception (createDeferred, D-40, #4067, 2026-10-14): 7.48 -> 7.50 KB (floor-caps.json) + // (20,436 -> 20,469 B minified), measured at 7,481 B against `next` + // @ 0109a56 — +14 B minified: the D11 check in `recompute`'s held-node + // arm (a held deferred re-pass joins nothing, 12 B) and the + // `REACTIVE_REASK` mask (2 B); `next` already sat 18-19 B over the + // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer 2026-10-14. The cap is frozen + // again at 7.50 KB. limit: floorCaps["signals: core floor (createSignal/Memo/Effect/Root/flush)"], capMinified: floorMinified["signals: core floor (createSignal/Memo/Effect/Root/flush)"], alias @@ -1044,8 +1052,15 @@ module.exports = [ // them their transaction instead of the committed screen. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // 2026-10-13. - limit: "14.94 KB", - capMinified: 45365, + // Size-Exception (createDeferred, D-40, 2026-10-15): 14.94 -> 15.00 KB, + // measured at 14,989 B with `next` @ b2c6e4ba merged in (45,365 -> + // 45,395 B minified). #4067's +17 B store share (the D11 check in + // `recompute`, the REACTIVE_REASK mask, the projection `fam` hook) plus + // #4073's seedless derived stores, each inside the 20 B allowance + // alone, together pass it. Cap set at measured + 10 B rounded up to + // 0.01 KB. Accepted by the maintainer (2026-10-14). + limit: "15.00 KB", + capMinified: 45395, alias }, { @@ -1321,8 +1336,15 @@ module.exports = [ // them their transaction instead of the committed screen. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // 2026-10-13. - limit: "9.77 KB", - capMinified: 27447, + // Size-Exception (createDeferred, D-40, #4067, 2026-10-14): 9.77 -> 9.79 KB + // (27,447 -> 27,480 B minified), measured at 9,773 B against `next` + // @ 0109a56 — +14 B minified: the D11 check in `recompute`'s held-node + // arm (a held deferred re-pass joins nothing, 12 B) and the + // `REACTIVE_REASK` mask (2 B); `next` already sat 18-19 B over the + // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer 2026-10-14. + limit: "9.79 KB", + capMinified: 27480, alias }, { @@ -1551,6 +1573,14 @@ module.exports = [ // instead of the committed screen. Cap set at measured + 10 B rounded up to // 0.01 KB. Accepted by the maintainer 2026-10-13; it stays under 10 KB. The // cap is frozen again at 9.98 KB. + // Size-Exception (createDeferred, D-40, #4067, 2026-10-14): 9.98 -> 10.01 KB (floor-caps.json) + // (27,945 -> 27,977 B minified), measured at 9,995 B against `next` + // @ 0109a56 — +14 B minified: the D11 check in `recompute`'s held-node + // arm (a held deferred re-pass joins nothing, 12 B) and the + // `REACTIVE_REASK` mask (2 B); `next` already sat 18-19 B over the + // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer 2026-10-14. Hello world stays + // under 10 KB at 9,995 B. The cap is frozen again at 10.01 KB. limit: floorCaps["app: render + one signal (the simple-app floor)"], capMinified: floorMinified["app: render + one signal (the simple-app floor)"], alias @@ -1919,6 +1949,13 @@ module.exports = [ // whose own flush joins that hold back into it. Cap set at measured + // 10 B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14 // (floor-caps.json; the floors unchanged, 0 B minified). + // Size-Exception (createDeferred, D-40, 2026-10-15; floor-caps.json): + // 18.09 -> 18.11 KB, measured at 18,092 B (53,350 -> 53,386 B minified) + // with `next` @ eb4e0192 merged in. #4067's core share (the D11 check + // in `recompute`, the REACTIVE_REASK mask) plus #4075's still-streaming + // branch, each inside the 20 B allowance alone, together pass it. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-14). limit: floorCaps["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], capMinified: floorMinified["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], alias @@ -2439,8 +2476,15 @@ module.exports = [ // branch of hydratedCreateLoadingBoundary skips the fallback when `$df` // already swapped the content in. Cap set at measured + 10 B rounded up // to 0.01 KB. Accepted by the maintainer 2026-10-15. - limit: "29.69 KB", - capMinified: 93753, + // Size-Exception (createDeferred, D-40, 2026-10-15): 29.69 -> 29.76 KB, + // measured at 29,747 B (93,753 -> 93,770 B minified) with + // `next` @ eb4e0192 merged in. #4067's core share (the D11 check + // in `recompute`, the REACTIVE_REASK mask) plus #4075's still-streaming + // branch, each inside the 20 B allowance alone, together pass it. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-14). + limit: "29.76 KB", + capMinified: 93770, alias }, { @@ -3554,8 +3598,15 @@ module.exports = [ // them their transaction instead of the committed screen. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // 2026-10-13. - limit: "10.17 KB", - capMinified: 28475, + // Size-Exception (createDeferred, D-40, #4067, 2026-10-14): 10.17 -> 10.21 KB + // (28,475 -> 28,507 B minified), measured at 10.19 KB in CI against `next` + // @ 0109a56 — +14 B minified: the D11 check in `recompute`'s held-node + // arm (a held deferred re-pass joins nothing, 12 B) and the + // `REACTIVE_REASK` mask (2 B); `next` already sat 18-19 B over the + // recorded minified. Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer 2026-10-14. + limit: "10.21 KB", + capMinified: 28507, alias }, { @@ -3784,8 +3835,15 @@ module.exports = [ // branch of hydratedCreateLoadingBoundary skips the fallback when `$df` // already swapped the content in. Cap set at measured + 10 B rounded up // to 0.01 KB. Accepted by the maintainer 2026-10-15. + // createDeferred (D-40, 2026-10-15): recorded minified 101,369 -> 101,387 + // (+18 B, measured at 31,821 B, under the cap) with + // `next` @ eb4e0192 merged in. #4067's core share (the D11 check + // in `recompute`, the REACTIVE_REASK mask) plus #4075's still-streaming + // branch, each inside the 20 B allowance alone, together pass it. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-14). limit: "31.89 KB", - capMinified: 101369, + capMinified: 101387, alias }, {