diff --git a/.changeset/verdict-pulled-pass-window-4429.md b/.changeset/verdict-pulled-pass-window-4429.md new file mode 100644 index 0000000000..0d38628fdb --- /dev/null +++ b/.changeset/verdict-pulled-pass-window-4429.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A memo pulled inside `isPending()` now computes as its own pass (A31): the probe's state no longer leaks into it, so a `latest()` it calls doesn't join the hold through the probe, and its reads don't answer the outer `isPending` (#4429). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 60cb02877c..ad0dee89ac 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -75,7 +75,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:95` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 seat.ts×3 types.ts×1 verdict.ts×2 store.ts×5 | continuation-sibling-reader-4146.test.ts×1 createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 issue-3800-repro.test.ts×1 latest-held-till-flush.test.ts×1 latest-memo-inline-parity.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | | A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:119` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×14 effect.ts×1 lanes.ts×1 scheduler.ts×4 seat.ts×1 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-branch-verdict-reader-4010.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 verdict-born-into-other-flush-4426.test.ts×1 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:259` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 projection.ts×1 | 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 projection-peek-loop-4342.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:133` | boundaries.ts×1 core.ts×1 lanes.ts×1 seat.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 unowned-memo-ispending-reader-4366.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 … | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:133` | 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 probe-pulled-pass-4429.test.ts×1 unowned-memo-ispending-reader-4366.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:141` | core.ts×4 lanes.ts×1 seat.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:275` | 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:285` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×6 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 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-guess-over-held-slot.test.ts×2 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… | diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index 8b0100f45e..876876265d 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -1314,25 +1314,6 @@ const known: KnownDifference[] = [ seat.creatorsWorld === WORLD_H ); } - }, - { - // `isPending`'s `probing` flag is left set around a memo's pass the - // probe pulled (`verdictValue` closes only the dispatch, A31): a - // `latest()` of a born-into-the-future node inside that pass takes the - // probe's `joinPass` arm and the pulled pass joins the hold. A verdict - // read raises no world (F-vworld); the slot — the staging — agrees. - issue: 4429, - kind: "world", - row: "Vl4", - expected: WORLD_S, - actual: WORLD_H, - when: (c, el, seat) => - (el!._config & CONFIG_HELD) !== 0 && - ((el as Computed)._statusFlags & STATUS_UNINITIALIZED) !== 0 && - el!._pendingValue !== NOT_PENDING && - !((el as Computed)._statusFlags & STATUS_PENDING) && - (c._flags & REACTIVE_JOINED) !== 0 && - seat.ownWorld < WORLD_H } ]; const knownHits = new Map(); diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 0bb3c4075b..bfc9b2c8a5 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -412,7 +412,12 @@ function verdictValue(el: Signal | Computed, c: Computed | null): // produced. So is a lazy or dormant memo's wake (an unowned memo with no // reader left, #4366): woken inside the window, it read its sources as the // probe does, the committed value under a landing. (A signal has no - // `_flags`: the wake is a no-op for it.) + // `_flags`: the wake is a no-op for it.) So is the probe's state (#4429): + // a `latest()` the pass makes itself does not join a hold through the + // probe's arm, and what the pass reads does not answer the outer probe + // (`probeFound` is only set while `probing`). + const prevProbing = probing; + probing = false; setVerdict(null); prepareComputed(owner, false); if (c && tracking) { @@ -420,6 +425,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): if ((el as any)._fn) pullComputed(owner, c); } setVerdict(verdictValue); + probing = prevProbing; // Shadow (S0): the window's decision point — the rows Vl*/Vp*. if (__TEST__) shadowExpect(c, el, tracking, latestActive ? 2 : 1, spectating, false); // A live `affects()` mark on the node or a dependency (affects.ts): not diff --git a/packages/signals/tests/probe-pulled-pass-4429.test.ts b/packages/signals/tests/probe-pulled-pass-4429.test.ts new file mode 100644 index 0000000000..e928d6c5a1 --- /dev/null +++ b/packages/signals/tests/probe-pulled-pass-4429.test.ts @@ -0,0 +1,142 @@ +/** + * #4429 — a memo's pass pulled inside a verdict window is its own (A31), + * whoever woke it. + * + * `verdictValue` pulls a dirty memo with the dispatch closed + * (`setVerdict(null)`), so the memo's plain reads are its own. It left the + * probe's state set, though: `probing` stayed on for the pulled pass, so a + * `latest()` the memo calls itself re-entered the window as the outer probe: + * + * - `latest()` of a node born held (no committed value, staged) took the + * probe's `joinPass` arm: the pulled memo joined the hold, and a memo it + * created during that pass was born held (D-27) — where the same pass + * pulled by a plain read leaves it mainline (F-vworld: a verdict read + * raises no world); + * - `probeFound` was set from inside the pulled pass (a flight its + * `latest()` read), so the outer `isPending` answered for the memo's + * reads rather than for the memo. + * + * Each case compares the pull inside the window with the same pass woken by + * a plain read: the memo's pass, its value and what it creates are the same. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest, + untrack +} from "../src/index.js"; + +const peek = (fn: () => unknown) => { + try { + return untrack(fn); + } catch (e) { + return (e as Error).constructor.name; + } +}; + +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +describe("#4429: a memo pulled inside a verdict window computes as its own pass", () => { + for (const pull of ["isPending", "plain"] as const) + it(`latest() of a born-held node in the pulled pass joins nothing (pulled ${pull})`, async () => { + const [x, setX] = createSignal(0); + const [s, setS] = createSignal(0); + const [on, setOn] = createSignal(false); + let born: (() => number) | null = null; + let child!: () => number; + const m1 = createRoot(() => { + createRenderEffect(x, () => {}); + // Deeper than the reader below: the reader's pass pulls it. + const sa = createMemo(() => s()); + const m1 = createMemo(() => { + const v = sa(); + const l = born ? latest(born) : -1; + // Reads nothing held: born held only if its creator joined. + child = createMemo(() => v * 100); + return l * 10 + v; + }); + createRenderEffect(m1, () => {}); + createRenderEffect( + () => on() && (pull === "isPending" ? isPending(() => m1()) : m1()), + () => {} + ); + return m1; + }); + flush(); + let release!: () => void; + action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + flush(); + + // Born held: no committed value, its staging (1) its only value. + createRoot(() => { + born = createMemo(() => x()); + }); + expect(peek(born!)).toBe("NotReadyError"); + setS(1); + setOn(true); + flush(); + // The pulled pass read `latest(born)` = 1 (D-22) and made its child + // from the screen's s: mainline — not the hold's. + expect(peek(m1)).toBe(11); + expect(peek(child)).toBe(100); + release(); + await settle(); + expect(peek(m1)).toBe(11); + expect(peek(child)).toBe(100); + }); + + for (const pull of ["isPending", "plain"] as const) + it(`a flight the pulled pass's latest() reads is not the probe's answer (pulled ${pull})`, async () => { + const [q, setQ] = createSignal(1); + const [s, setS] = createSignal(0); + const [on, setOn] = createSignal(false); + let resolve!: () => void; + const seen: unknown[] = []; + const m1 = createRoot(() => { + const a = createMemo(async () => { + const v = q(); + if (v > 1) await new Promise(r => (resolve = r)); + return v; + }); + createRenderEffect(a, () => {}); + const sa = createMemo(() => s()); + const m1 = createMemo(() => sa() + latest(a) * 10); + createRenderEffect(m1, () => {}); + createRenderEffect( + () => on() && (pull === "isPending" ? isPending(() => m1()) : (m1(), isPending(m1))), + v => { + seen.push(v); + } + ); + return m1; + }); + await settle(); + expect(peek(m1)).toBe(10); + setQ(2); + flush(); + // a is in flight over its committed 1. + setS(1); + setOn(true); + flush(); + // m1 is a settled sync memo of `latest(a)` — the committed 1 while the + // flight is up: nothing m1 shows is waiting on it. + expect(peek(m1)).toBe(11); + expect(seen).toEqual([false, false]); + resolve(); + await settle(); + expect(peek(m1)).toBe(21); + }); +});