From 6eda9c7536f1fb3a214da7af45d55117e0ac1b85 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 14 Sep 2026 17:57:22 -0500 Subject: [PATCH] fix(signals): a memo computes under its own lane posture, never its puller's (#3442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A combined `isPending(() => [fast(), copy()])` over two async memos, with `copy` a sync memo wrapping the slow one, released the hold as soon as the fast flight landed: `Fast: 1` beside `Slow: 0` with `Pending: false`, then `Slow: 1` a second later. The probe effect carries the companion lane of the pending signals it reads, and its pull of `copy` ran under that lane — where a pending node on no lane serves its committed value instead of throwing — so `copy` published a stale settled `0`, dropped its pending status, and its readers stopped holding `slow`: the transaction settled on `fast`'s landing with `slow` still in flight. A plain getter in place of `copy` never had the gap (the probe read `slow` directly, and a probe observes without deriving). recompute now clears the ambient lane for a non-effect node before the lane branches re-establish the posture the memo itself owns (OPT-dirty, or adopted through its deps). A memo's value is one shared slot every reader sees, so the puller's lane carve-outs must not shape it; effects keep the ambient lane, since their runs are the lane's own view. Both values now reveal together, with the probe reporting pending until they do. Spec rule A31. +12 B core floor (noted, no bump); two brotli caps ratcheted 0.05 KB with measurements against `next`. Rules index regenerated, changeset included. Co-Authored-By: Claude Fable 5.1 --- .changeset/fix-memo-pull-under-lane.md | 7 ++ packages/signals/docs/RULES-INDEX.md | 75 ++++++------- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 8 ++ packages/signals/src/core/core.ts | 11 ++ .../ispending-combined-atomic-3442.test.ts | 104 ++++++++++++++++++ packages/signals/tests/treeshake.test.ts | 3 + .../ispending-combined-atomic-3442.spec.tsx | 72 ++++++++++++ scripts/size/.size-limit.js | 5 + 8 files changed, 248 insertions(+), 37 deletions(-) create mode 100644 .changeset/fix-memo-pull-under-lane.md create mode 100644 packages/signals/tests/ispending-combined-atomic-3442.test.ts create mode 100644 packages/web/test/ispending-combined-atomic-3442.spec.tsx diff --git a/.changeset/fix-memo-pull-under-lane.md b/.changeset/fix-memo-pull-under-lane.md new file mode 100644 index 000000000..b7a69cc24 --- /dev/null +++ b/.changeset/fix-memo-pull-under-lane.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +--- + +A memo computes under its own lane posture, never its puller's (#3442). + +A combined `isPending(() => [fast(), copy()])` over two async memos, with `copy` a sync memo wrapping the slow one, released the hold as soon as the fast flight landed: `Fast: 1` beside `Slow: 0` with `Pending: false`, then `Slow: 1` a second later. The probe effect carries the companion lane of the pending signals it reads, and its pull of `copy` ran under that lane — where a pending node on no lane serves its committed value instead of throwing — so `copy` published a stale settled value, dropped its pending status, and its readers stopped holding the slow flight. `recompute` now runs a memo plain unless the memo itself is lane-dirty or adopts a lane through its dependencies; effects keep the ambient lane, since their runs are the lane's own view. Both values now reveal together, with the probe reporting pending until they do. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index db8c33e51..753894092 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -22,7 +22,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | vocabulary | rules | cited in src | cited in tests | cited nowhere | | ---------- | ----- | ------------ | -------------- | ------------- | -| A | 29 | 11 | 29 | 0 | +| A | 30 | 12 | 30 | 0 | | V | 5 | 2 | 5 | 0 | | B | 5 | 0 | 5 | 0 | | C | 4 | 0 | 3 | 1 | @@ -45,63 +45,64 @@ 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:213` | — | 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:221` | — | 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:229` | — | 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:237` | — | 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:245` | — | 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:253` | — | 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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:93` | — | spec-async-semantics.test.ts×2 | [ruled] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | createMemo.test.ts×1 | [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:109` | — | spec-async-semantics.test.ts×3 | [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:117` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [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. | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:221` | — | 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:229` | — | 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:237` | — | 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:245` | — | 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:253` | — | 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:261` | — | 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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | spec-async-semantics.test.ts×2 | [ruled] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | createMemo.test.ts×1 | [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:117` | — | spec-async-semantics.test.ts×3 | [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:125` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [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:51` | — | latest-isPending-consistency.test.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:125` | — | 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:133` | async.ts×1 | 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:141` | — | spec-async-semantics.test.ts×2 | [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:175` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 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 treeshake.test.ts×3 | [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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:149` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 | [ruled 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 uninitialized asy… | +| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | 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:141` | async.ts×1 | 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:149` | — | spec-async-semantics.test.ts×2 | [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:183` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 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 treeshake.test.ts×3 | [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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 | [ruled 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 uninitialized asy… | | 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×1 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 | [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 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:77` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 | [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:265` | 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:272` | — | 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:157` | — | spec-async-semantics.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:165` | — | 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:85` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 | [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:203` | — | uninitialized-visibility.test.ts×3 | [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… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:85` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 | [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:273` | 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:280` | — | 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:165` | — | spec-async-semantics.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:173` | — | 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:93` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 | [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:211` | — | uninitialized-visibility.test.ts×3 | [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 | [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:195` | — | loading-value.test.ts×2 | [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:203` | — | loading-value.test.ts×2 | [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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 | held-conditional-memo.test.ts×1 | [ruled 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 live transaction h… | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:183` | 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:191` | 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… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×1 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:335` | 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:345` | 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:351` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:358` | — | 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:370` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:343` | 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:353` | 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:359` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:366` | — | 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:378` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------------------------------------------------------- | -------------------------------------------------- | -| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | -| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | -| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:175` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | +| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | +| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | +| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:183` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | | B4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:41` | — | spec-async-semantics.test.ts×2 | PROMOTED → A18 (A18's section carries the ruling). | -| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | +| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | ## C — tier C | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | -------------------------------------------------- | --------------------------------------------------------------------------- | -| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:77` | — | 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:301` | — | 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:311` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:85` | — | 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:309` | — | 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:319` | — | — | - [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 1397fd071..98f8b6896 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -72,6 +72,14 @@ Sync derivations of transition-held sources are visible through `latest()`/`isPe A tracked computation served a node's staged `_pendingValue` — a value a live transaction holds — derives from that transaction's world, so its pass enters the transaction and its result is held with it. This is the read twin of the two entries that already existed, `setSignal` on a stamped node and `recompute` of a stamped node, and closes the gap between them: a conditional memo whose branch flipped mainline (`panel = show() ? count() : "hidden"`) started reading a held `count`, was served the staged value (non-stale readers keep speculation), and published a value derived from the held world into the mainline frame — `Panel: 1` beside `Count: 0`. A stale (render) reader is unchanged: the reveal carve-out (A15) serves it the committed value with no entanglement, which is why the same shape written as a plain JSX expression already showed a coherent frame. A probe (`isPending(() => x())`) observes, it does not derive, so it enters nothing (A23). +### A31. A memo computes under its own lane posture, never its puller's + +**Status:** **live** 2026-09-14 (#3442) — stated by the fix; the lane-side twin of A29's "a value derived from the held world is that transaction's work" +**Pinned by:** `tests/ispending-combined-atomic-3442.test.ts` (#3442: a combined `isPending` over two async memos, one wrapped by a sync memo, holds both until both land) +**Mechanism (index, 2026-09-14):** `recompute` clears `currentOptimisticLane` for a non-effect node before the lane branches (`recomputeLane(el, true)` for an OPT-dirty node, `recomputeLane(el, false)` adoption through deps) re-establish the memo's own posture; effects keep the ambient lane. + +A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself owns — OPT-dirty, or adopted through its dependencies — and never under the lane of whichever reader happened to pull it. Lane posture changes what a read serves: under a lane, a pending node on no lane (or another lane) serves its committed value instead of throwing, and the entanglement gates serve committed values for the lane's own view. Those carve-outs are sound for the lane's effects, whose runs are that view, and unsound for a memo, whose result is cached for everyone. Before: the probe effect of `isPending(() => [fast(), copy()])` carried the companion lane of the pending signals it reads, and its pull of `copy = createMemo(() => slow())` ran under it; `copy` read the in-flight `slow` as its committed `0`, published a clean value, dropped its pending status, and its readers stopped holding `slow` — the transaction settled on `fast`'s landing with `slow` still in flight (`Fast: 1` beside `Slow: 0`, `Pending: false`). Now the pull throws `NotReady` as a plain reader would, `copy` stays pending, and the hold lasts until both flights land. A plain getter in place of `copy` never had the gap: the probe read `slow` directly, and a probe observes without deriving (A23). + ## Verdicts — `isPending()` and `latest()` ### A19. `isPending(x)` ≡ the observable value is not final (three causes) diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 2fb7f19fe..1d8c6da4b 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -318,6 +318,17 @@ export function recompute(el: Computed, create: boolean = false): void { // covers creation-time computes and flushes that run inside the window. const prevLatestRead = latestReadActive; latestReadActive = false; + // A memo computes under its OWN lane posture, never the puller's (A31, + // #3442): its value is one shared slot every reader sees, so a pull from a + // lane-carrying reader (a probe effect on its companion lane pulling a sync + // memo) must not run it with that lane's read carve-outs — under a lane, a + // pending node on no lane serves its committed value instead of throwing, + // and the memo then published a stale "settled" value, dropped its + // pending status, and stopped holding its transaction. The branches below + // re-establish the posture the memo itself owns (OPT-dirty, or adopted + // through its deps). Effects keep the ambient lane: their runs are the + // lane's own view. + if (!isEffect) currentOptimisticLane = null; // Lane posture lives with the engine: OPTIMISTIC_DIRTY is only ever set by // engine-driven paths, and _optimisticNodes is only pushed by // _optimisticWrite, so the hook is installed whenever either gate holds. diff --git a/packages/signals/tests/ispending-combined-atomic-3442.test.ts b/packages/signals/tests/ispending-combined-atomic-3442.test.ts new file mode 100644 index 000000000..70d9903b0 --- /dev/null +++ b/packages/signals/tests/ispending-combined-atomic-3442.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending +} from "../src/index.js"; + +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 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); + } + }); +} + +async function scenario(variant: "issue" | "no-pending" | "getter") { + now = 0; + timers = []; + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, sC] = createSignal(0); + setCount = sC; + const slow = createMemo(() => delay(1000, count())); + const fast = createMemo(async () => count()); + const copy = variant === "getter" ? () => slow() : createMemo(() => slow()); + if (variant !== "no-pending") + text(() => `Pending: ${isPending(() => [fast(), copy()])}`, log, when); + text(() => `Fast: ${fast()}`, log, when); + text(() => `Slow: ${copy()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2000); + setCount(1); + await settle(); + await advanceTo(5000); + return frames(log, when); +} + +describe("combined isPending read across two async memos (#3442)", () => { + // The write (2000) starts two flights; `fast` lands in a microtask and is + // held with `slow` (1000ms). The probe effect carries the companion lane of + // the pending signals it reads, and its pull of `copy` (a sync memo over + // `slow`) used to run under that lane, where a pending node on no lane + // serves its committed value instead of throwing: `copy` published a + // stale settled `0`, dropped its pending status, and its readers stopped + // holding `slow` — the hold released with `slow` still in flight + // (`Fast: 1` beside `Slow: 0`, `Pending: false`). A memo now computes + // under its own lane posture only. + const atomic = [ + "1000: Fast: 0 | Pending: false | Slow: 0", + "2000: Pending: true", + "3000: Fast: 1 | Pending: false | Slow: 1" + ]; + it("A31 / #3442 isPending(() => [fast(), copy()]) with copy a sync memo holds both flights", async () => { + expect(await scenario("issue")).toEqual(atomic); + }); + it("control: copy is a plain getter", async () => { + expect(await scenario("getter")).toEqual(atomic); + }); + it("control: no pending read", async () => { + expect(await scenario("no-pending")).toEqual([ + "1000: Fast: 0 | Slow: 0", + "3000: Fast: 1 | Slow: 1" + ]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 54ea7a898..229a63528 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -283,6 +283,9 @@ describe("pay-for-use tree-shaking (#2883)", () => { // recompute's tail keeps an effect's dependency tail while a run is owed // (`_modified`), and runEffect trims it once the run applies. Measured // at 23,353 post-change. + // NOTE (2026-09-14, no bump): +12 B for memo lane posture (#3442) — one + // assignment at recompute's head runs a memo plain unless it owns or + // adopts a lane. Measured at 23,365 on top of #3438 (23,353 → 23,365). expect(minifiedBytes).toBeLessThan(23_400); }); diff --git a/packages/web/test/ispending-combined-atomic-3442.spec.tsx b/packages/web/test/ispending-combined-atomic-3442.spec.tsx new file mode 100644 index 000000000..745b1f75b --- /dev/null +++ b/packages/web/test/ispending-combined-atomic-3442.spec.tsx @@ -0,0 +1,72 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +import { describe, expect, test } from "vitest"; +import { createMemo, createSignal, flush, isPending } 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 https://s.olid.uk/id/P4Zyb5IlR_Gmz8H0dvcBUQ, 1000ms -> 100ms. +function App(props: { variant: "issue" | "no-pending" | "getter" }) { + const [count, setCount] = createSignal(0); + const slow = createMemo(() => delay(100, count())); + const fast = createMemo(async () => count()); + const copy = props.variant === "getter" ? () => slow() : createMemo(() => slow()); + return ( + <> + + {props.variant === "no-pending" ? ( +

Pending: n/a

+ ) : ( +

Pending: {String(isPending(() => [fast(), copy()]))}

+ )} +

Fast: {fast()}

+

Slow: {copy()}

+ + ); +} + +async function run(variant: "issue" | "no-pending" | "getter") { + const div = document.createElement("div"); + document.body.appendChild(div); + const dispose = render(() => , div); + await delay(150); + flush(); + const initial = snapshot(div); + div.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const log: string[] = []; + for (let t = 0; t <= 140; t += 20) { + flush(); + log.push(`t=${t}: ${snapshot(div)}`); + await delay(20); + } + process.stderr.write(`ISSUE-3442 ${variant}\ninitial: ${initial}\n${log.join("\n")}\n`); + dispose(); + div.remove(); + expect(initial).toMatch(/Fast: 0 \| Slow: 0$/); + expect(log.at(-1)).toMatch(/Fast: 1 \| Slow: 1$/); + // Fast and Slow must never disagree in a published frame, and while they + // are held the combined probe reports pending. + for (const line of log) { + expect(line, line).not.toMatch(/Fast: 1 \| Slow: 0/); + if (variant !== "no-pending" && / Fast: 0 \| Slow: 0$/.test(line)) + expect(line, line).toMatch(/Pending: true/); + } +} + +describe("combined isPending read across two async memos (#3442)", () => { + test("issue: isPending(() => [fast(), copy()]) with copy = createMemo(() => slow())", () => + run("issue")); + test("control: no pending read", () => run("no-pending")); + test("control: copy is a plain getter", () => run("getter")); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 2fc4805d8..9c3bebf34 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -443,6 +443,9 @@ module.exports = [ // 15636 B against `next`'s 15580 (+56 brotli on +35 B minified — the // `_modified` gate on recompute's trim and runEffect's trim); see the // core floor note. + // Memo lane posture (#3442, 2026-09-14): no bump, measured at 15624 B on + // the rebase over #3438 (+12 B minified — one assignment in recompute's + // head; brotli noise absorbs it; see the core floor note). limit: "15.65 KB", modifyEsbuildConfig }, @@ -749,6 +752,8 @@ module.exports = [ // Effect arm of A30 (#3438, 2026-09-14): 18.85 -> 18.90 KB, measured at // 18880 B against `next`'s 18816 (+64 brotli on +35 B minified); see the // core floor note. + // Memo lane posture (#3442, 2026-09-14): no bump, measured at 18854 B on + // the rebase over #3438 (+12 B minified; brotli noise absorbs it). limit: "18.90 KB", modifyEsbuildConfig },