Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/verdict-pulled-pass-window-4429.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Loading>` 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… |
Expand Down
19 changes: 0 additions & 19 deletions packages/signals/src/core/seat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>)._statusFlags & STATUS_UNINITIALIZED) !== 0 &&
el!._pendingValue !== NOT_PENDING &&
!((el as Computed<any>)._statusFlags & STATUS_PENDING) &&
(c._flags & REACTIVE_JOINED) !== 0 &&
seat.ownWorld < WORLD_H
}
];
const knownHits = new Map<number, number>();
Expand Down
8 changes: 7 additions & 1 deletion packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,14 +412,20 @@ function verdictValue(el: Signal<any> | Computed<any>, c: Computed<any> | 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) {
link(el, c);
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
Expand Down
142 changes: 142 additions & 0 deletions packages/signals/tests/probe-pulled-pass-4429.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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<void>(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);
});
});
Loading