From c24e38e588a3267206a6b191ef626f9eb60901d2 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 15 Sep 2026 08:34:19 -0500 Subject: [PATCH] fix(signals): an async memo's held landing keeps the committed frame's dependencies (#3461) `selected = createMemo(async () => (b() ? b() : a()))` with `b` held by a slow flight: selected's held pass read only `b`, and asyncWrite trimmed the dependency tail unconditionally before the write, so the `selected -> a` edge was gone even though the landed value was then staged under the hold. The committed `Selected: 0` still derived from `a`, but the mainline `a` write no longer reached selected or entered its hold through the stamp, and committed beside the stale derivation: `A: 1 | B: 0 | Selected: 0`. The landing now follows A30's gate (the arm `recompute` and `commitPendingNode` already implement for sync passes and effects): trim after the write, only when the landing published (`_pendingValue === NOT_PENDING`); a transition-held landing leaves the tail for its commit. The `a` write reaches selected, joins the hold, and the four values reveal as one frame, identical to the sync control. Spec: A30 gains the landing arm; rules index regenerated. Core floor flat (23,752); brotli core-floor cap ratcheted 8.85 -> 8.90 KB for +2 B of noise. Changeset included. Co-Authored-By: Claude Fable 5.1 --- .changeset/async-landing-deps.md | 7 ++ packages/signals/docs/RULES-INDEX.md | 36 +++--- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 6 +- packages/signals/src/core/async.ts | 10 +- .../tests/async-landing-deps-3461.test.ts | 107 ++++++++++++++++++ .../web/test/async-landing-deps-3461.spec.tsx | 77 +++++++++++++ scripts/size/.size-limit.js | 7 +- 7 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 .changeset/async-landing-deps.md create mode 100644 packages/signals/tests/async-landing-deps-3461.test.ts create mode 100644 packages/web/test/async-landing-deps-3461.spec.tsx diff --git a/.changeset/async-landing-deps.md b/.changeset/async-landing-deps.md new file mode 100644 index 000000000..2ffec5173 --- /dev/null +++ b/.changeset/async-landing-deps.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +--- + +An async memo's held landing keeps the committed frame's dependencies (#3461). + +- `selected = createMemo(async () => (b() ? b() : a()))` with `b` held by a slow flight: selected's held pass read only `b`, and its landing trimmed `a` at once, before the write staged the landed value under the hold. A later mainline `a` write no longer reached selected, so `A: 1` committed beside `Selected: 0` while `B` still read 0. The landing now trims only when it published (A30, the landing twin of a staged sync pass); a transition-held landing leaves the tail for its commit, so the `a` write reaches selected and joins the hold, and the four values reveal as one frame. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 1248f7082..f7bd20b4c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -45,12 +45,12 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:237` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:253` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | — | 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:269` | — | 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. | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:231` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:239` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:247` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:255` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:263` | — | 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:271` | — | 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:109` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×2 | [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:117` | — | createMemo.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.test.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:125` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [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… | @@ -64,16 +64,16 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.test.ts×12 visibility-oracle.test.ts×26 | [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:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.test.ts×25 | [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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:93` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×12 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:281` | invariants.ts×1 | 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:288` | — | 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… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:283` | invariants.ts×1 | 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:290` | — | 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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.test.ts×1 | [ruled 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) and the decree tha… | | A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | 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:101` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×2 visibility-oracle.test.ts×4 | [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:219` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [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… | +| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:221` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [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:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×2 | [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:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×19 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:213` | — | loading-value.test.ts×2 visibility-oracle.test.ts×19 | [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… | | A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×4 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.test.ts×6 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [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… | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [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:75` | core.ts×2 | 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:83` | — | visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×10 | [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… | @@ -81,11 +81,11 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:351` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:361` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:367` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:374` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | -| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:386` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:353` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:363` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:369` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:376` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | +| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:388` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B @@ -102,8 +102,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | -------------------------------------------------- | --------------------------------------------------------------------------- | | C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:93` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | -| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:317` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | -| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:327` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:319` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | +| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:329` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | | C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:31` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | ## INV — invariants diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 441bb080e..2e5155336 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -199,13 +199,15 @@ A resting optimistic node reports pending via exactly the causes a plain async m ### A30. A memo's dependencies are the committed frame's until the frame is replaced **Status:** **ruled** 2026-09-13 (#3410) — maintainer ruling, Cluster 4 triage; the dependency twin of held children (#3404) -**Pinned by:** `tests/held-conditional-memo.test.ts` (#3410: a memo whose held pass stopped reading an input still follows a mainline write to it); `tests/held-conditional-effect.test.ts` (#3438: an effect whose held pass stopped reading an input, its run stashed, still follows a mainline write to it) -**Mechanism (index, 2026-09-14):** `recompute` trims the previous pass's dependency tail (`trimStaleDeps`) only when the pass published or changed nothing (`_pendingValue === NOT_PENDING`, no `_error`) and, for an effect, owes no run (`_modified` clear); a pass that staged its value leaves the tail linked and `commitPendingNode` trims it after a clean pass (`_error == null`); an effect pass that direct-committed and owes a run leaves it for `runEffect` to trim once the run applies (again after a clean pass). `__OBSERVE__` fan-in counting and the attribution engine's subscription diff walk the validated prefix only. +**Pinned by:** `tests/held-conditional-memo.test.ts` (#3410: a memo whose held pass stopped reading an input still follows a mainline write to it); `tests/held-conditional-effect.test.ts` (#3438: an effect whose held pass stopped reading an input, its run stashed, still follows a mainline write to it); `tests/async-landing-deps-3461.test.ts` (#3461: an async memo whose held flight stopped reading an input, its landing staged, still follows a mainline write to it) +**Mechanism (index, 2026-09-15):** `recompute` trims the previous pass's dependency tail (`trimStaleDeps`) only when the pass published or changed nothing (`_pendingValue === NOT_PENDING`, no `_error`) and, for an effect, owes no run (`_modified` clear); a pass that staged its value leaves the tail linked and `commitPendingNode` trims it after a clean pass (`_error == null`); an effect pass that direct-committed and owes a run leaves it for `runEffect` to trim once the run applies (again after a clean pass). `__OBSERVE__` fan-in counting and the attribution engine's subscription diff walk the validated prefix only. An async landing (`asyncWrite`) follows the same gate (#3461): it trims only when it published (`_pendingValue === NOT_PENDING` after the write, an equal-value or lane landing); a transition-held landing leaves the tail for `commitPendingNode`. A pass that _staged_ its value has not replaced the committed frame, so the committed value still derives from the previous pass's dependencies and a write to one of them must reach the node — and, through the node's `_transition` stamp, join its hold — exactly as an unconditional read would. Before: `selected = fixed() ? 2 : count()` held on `fixed → true` dropped `count` at its held pass, and a mainline `count` write then published `Count: 1` beside the committed `Selected: 0` / `Fixed: false`. Decided at commit rather than at the pass because a plain flush knows nothing at recompute time: the transaction that ends up holding the pass may open later in the same flush (an async memo downstream pends and the batch is adopted). An errored pass (a throw, NotReady included, a comparator throw) keeps its full list as before, and the commit skips the trim by the same `_error`. Cost on the plain path: none — a pass that publishes trims at its tail as before; only a staged pass moves the trim to the same flush's commit. An effect's frame is the run its value is applied by, not its value slot (#3438). A plain-flush pass direct-commits `_value` and enqueues the run, but the same flush can still become a hold (the async memo downstream pends, the batch is adopted) and stash that run with the transaction — so the committed frame is still what the _last_ run published, and it still derives from the previous pass's dependencies. Before: `{show() ? count() : "hidden"}` (the compiler's insert effect) held on `show → false` dropped `count` at its pass, and the mainline `count` write then published `Count: 1` beside `Panel: 0` while `Show` still read `true`. Now the pass leaves the tail while a run is owed and `runEffect` trims once it applies. The write reaches the effect; a render effect is a mainline reader of the foreign hold (it is served the committed `show`, A15's stale-reader term), so it re-derives `Panel: 1` beside `Count: 1` in the mainline frame, and the hold's reveal re-derives it to `hidden` — unlike a memo, which is served the staged value and joins the hold (A29). Both frames are coherent; the shapes differ because effects render mainline by design. A pass that changed nothing owes no run and trims at its tail as before. +An async memo's frame is replaced by its landing, not by the pass that registered the flight (#3461). The flight's pass throws `NotReady` and keeps its full list; the landing used to trim unconditionally, before the write, even when `setSignal` then staged the value under a live transaction. Before: `selected = async () => (b() ? b() : a())` held on `b → 1` dropped `a` at its landing, and a mainline `a` write then published `A: 1` beside `Selected: 0` while `B` still read 0. Now the landing trims only when it published; a held landing leaves the tail for its commit, so the `a` write reaches `selected` and joins its hold (its stamp), exactly as the sync memo's staged pass does. Unchanged by design: a landing equal to the committed value published nothing new and trims at once, like a sync pass that changed nothing, so `b() ? 0 : a()` still drops `a` in both shapes. + ## Loading window and seeds ### A27. The commit-#0 loading window is loading-class and verdict-quiet diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index f39871a57..c9b2ded0b 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -484,7 +484,6 @@ export function handleAsync( // old value as pending, a one-frame pulse to direct observers (#3178). // A truthy capture implies `_x` exists, so the restore writes it directly. const wasReask = el._x?._reask; - trimStaleDeps(el); landStatus(el); if (wasReask) el._x!._reask = true; const lane = resolveLane(el as any); @@ -590,6 +589,15 @@ export function handleAsync( if (el._pendingValue === NOT_PENDING) { el._loading = false; if (wasReask) el._x!._reask = false; + // The landing published: the dependency tail the flight's pass left + // linked goes now (A30, #3410). A transition-held landing has not + // replaced the committed frame — the committed value still derives + // from the previous pass's inputs, and a mainline write to one of them + // must reach this node and join its hold (its stamp) instead of + // publishing beside the stale derivation (#3461: `b() ? b() : a()` + // held on `b` dropped `a` at its landing, and `A: 1` then committed + // beside `Selected: 0`). `commitPendingNode` trims a held landing. + trimStaleDeps(el); } settlePendingSource(el); schedule(); diff --git a/packages/signals/tests/async-landing-deps-3461.test.ts b/packages/signals/tests/async-landing-deps-3461.test.ts new file mode 100644 index 000000000..9f341e313 --- /dev/null +++ b/packages/signals/tests/async-landing-deps-3461.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js"; + +// Manual clock (see async-chain-supersession.test.ts). +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > t) break; + timers.shift(); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} +function frames(log: string[], when: number[]): string[] { + const byTime = new Map(); + log.forEach((v, i) => (byTime.get(when[i]) ?? byTime.set(when[i], []).get(when[i])!).push(v)); + return [...byTime].map(([t, vs]) => `${t}: ${vs.sort().join(" | ")}`); +} +function text(fn: () => string, log: string[], when: number[]) { + let last: string | undefined; + createRenderEffect(fn, v => { + if (v !== last) { + last = v; + log.push(v); + when.push(now); + } + }); +} + +// #3461: `selected = async () => (b() ? b() : a())`. `setB(1)` is held by `slow`; +// selected's held pass reads only `b` and its flight lands into the hold. The +// landing used to trim `a` at once, so the later mainline `setA(1)` never +// reached selected: `A: 1` committed beside `Selected: 0` while `B` still read +// 0. A30: the landed value is staged, the committed 0 still derives from `a`. +async function scenario(asyncSelected: boolean) { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void; + let setB!: (v: number) => void; + createRoot(() => { + const [a, sA] = createSignal(0); + const [b, sB] = createSignal(0); + setA = sA; + setB = sB; + const slow = createMemo(() => delay(2000, b())); + const selected = asyncSelected + ? createMemo(async () => (b() ? b() : a())) + : createMemo(() => (b() ? b() : a())); + text(() => `A: ${a()}`, log, when); + text(() => `B: ${b()}`, log, when); + text(() => `Slow: ${slow()}`, log, when); + text(() => `Selected: ${selected()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + setB(1); + await settle(); + await advanceTo(3500); + setA(1); + await settle(); + await advanceTo(6000); + return frames(log, when); +} + +describe("async memo landing keeps the committed frame's dependencies", () => { + it("A30 / #3461 a held async landing does not trim the input its committed value derives from", async () => { + // The `a` write (3500) reaches selected through the dependency its + // committed value still has, and joins the hold through selected's + // stamp: one frame when slow lands, never `A: 1` beside `Selected: 0`. + // (The async memo's first landing is held with the mount's slow flight + // and reveals with it at 2000; the sync control publishes it at 0.) + expect(await scenario(true)).toEqual([ + "0: A: 0 | B: 0", + "2000: Selected: 0 | Slow: 0", + "5000: A: 1 | B: 1 | Selected: 1 | Slow: 1" + ]); + }); + + it("A30 / #3410 sync control: the same conditional as a plain memo", async () => { + expect(await scenario(false)).toEqual([ + "0: A: 0 | B: 0 | Selected: 0", + "2000: Slow: 0", + "5000: A: 1 | B: 1 | Selected: 1 | Slow: 1" + ]); + }); +}); diff --git a/packages/web/test/async-landing-deps-3461.spec.tsx b/packages/web/test/async-landing-deps-3461.spec.tsx new file mode 100644 index 000000000..5321fa80e --- /dev/null +++ b/packages/web/test/async-landing-deps-3461.spec.tsx @@ -0,0 +1,77 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +import { describe, expect, test } from "vitest"; +import { createMemo, createSignal, flush } from "solid-js"; +import { render } from "@solidjs/web"; + +const delay = (ms: number, value?: T) => + new Promise(r => setTimeout(r, ms, value as T)); + +function snapshot(div: HTMLElement) { + return Array.from(div.querySelectorAll("p")) + .map(p => p.textContent) + .join(" | "); +} + +// Exact port of the issue's playground (https://s.olid.uk/id/XOQbd6CFTfy2QhZK4EiQ7g), +// timings scaled 2000/500 -> 200/50. +function App(props: { asyncSelected: boolean }) { + const [a, setA] = createSignal(0); + const [b, setB] = createSignal(0); + const slow = createMemo(() => delay(200, b())); + const selected = props.asyncSelected + ? createMemo(async () => (b() ? b() : a())) + : createMemo(() => (b() ? b() : a())); + return ( + <> + +

A: {a()}

+

B: {b()}

+

Slow: {slow()}

+

Selected: {selected()}

+ + ); +} + +async function run(asyncSelected: boolean) { + const div = document.createElement("div"); + document.body.appendChild(div); + const dispose = render(() => , div); + await delay(250); + flush(); + expect(snapshot(div)).toBe("A: 0 | B: 0 | Slow: 0 | Selected: 0"); + + div.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const log: string[] = []; + for (let t = 0; t <= 300; t += 20) { + flush(); + log.push(`t=${t}: ${snapshot(div)}`); + await delay(20); + } + dispose(); + div.remove(); + + expect(log.at(-1)).toBe("t=300: A: 1 | B: 1 | Slow: 1 | Selected: 1"); + // While B still reads 0, Selected (b() ? b() : a()) must agree with A: the + // `a` write joins the hold rather than publishing beside the stale derivation. + for (const line of log) { + const m = /A: (\d) \| B: 0 \| Slow: 0 \| Selected: (\d)/.exec(line); + if (m) expect(m[2], line).toBe(m[1]); + } +} + +describe("async conditional memo across a held branch change (#3461)", () => { + test("A30 / #3461 `createMemo(async () => b() ? b() : a())`", () => run(true)); + test("plain createMemo conditional (control)", () => run(false)); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 4ddb18190..62741bf61 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -237,7 +237,12 @@ module.exports = [ // mainline). +316 B minified in the in-package floor (23,365 -> 23,681). // Rebased over #3443/#3444 (2026-09-15): measured at 8820 B against // `next`'s 8708 (+112); 23,419 -> 23,753 minified. - limit: "8.85 KB", + // Async landing keeps the committed frame's deps (A30 landing arm, + // #3461, 2026-09-15): 8.85 -> 8.90 KB, measured at 8851 B against + // `next`'s 8849 (+2, brotli noise for a moved call: asyncWrite's + // trimStaleDeps now runs after the write, only when the landing + // published; 0 B minified in the in-package floor, 23,752 flat). + limit: "8.90 KB", modifyEsbuildConfig }, {