Skip to content

Commit ab3d2ad

Browse files
ryansolidclaude
andauthored
fix(signals): close the probe's state around a memo isPending pulls (#4429) (#4465)
* fix(signals): close the verdict window's state for a memo it pulls (#4429) `verdictValue` pulls a dirty memo with the dispatch closed (`setVerdict(null)`, A31: the pulled memo's pass is its own) but left `probing` and `latestActive` set. A `latest()` / `isPending()` the pulled memo made itself re-entered the window with the outer probe's state: - `latest()` of a born-held node took the probe's `joinPass` arm, so the memo's pass joined the hold and its value was held with it; - a flight its `latest()` read set `probeFound`, so the outer `isPending` answered for the memo's reads, not for the memo; - inside `latest(fn)`, the memo's own `isPending` of a held write read as a probe inside a `latest` window and answered final. Each depended on who woke the memo. Save and clear both flags around the pull, as the dispatch already is. `probeFound` needs no save: it is only set while `probing`. Fixes #4429 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * fix(signals): close only the probe's state around a pulled memo (#4429) Narrow the fix to `probing`, the state #4429 names: +9 B brotli / +13 B minified on `+ isPending/latest`, where clearing `latestActive` too cost +25 / +25. `latestActive` leaks the same way (inside `latest(fn)`, a pulled memo's own `isPending` of a held write answers final); that is left for its own issue and decision. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * chore: keep the #4425 spec as next has it (undo the merge's formatter pass) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * chore(size): raise the compiled live page's cap for #4429 (+13 B minified) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * chore(size): raise the live server components floor for #4429 (+13 B minified) Frozen floor 38.76 KB -> 38.80 KB, recorded minified 117,227 -> 117,328 B (+13 B this fix; the rest is next's growth since the floor was recorded). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * test(signals): drop the seat shadow's known entry for #4429 The probe now restores `probing` around a pulled pass, so the entry can only hide a regression. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --------- Co-authored-by: Claude <[email protected]>
1 parent 512ecfe commit ab3d2ad

5 files changed

Lines changed: 155 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
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).

‎packages/signals/docs/RULES-INDEX.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
7575
| 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… |
7676
| 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 … |
7777
| 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… |
78-
| 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 … |
78+
| 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 … |
7979
| 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… |
8080
| 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… |
8181
| 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… |

‎packages/signals/src/core/seat.ts‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,25 +1314,6 @@ const known: KnownDifference[] = [
13141314
seat.creatorsWorld === WORLD_H
13151315
);
13161316
}
1317-
},
1318-
{
1319-
// `isPending`'s `probing` flag is left set around a memo's pass the
1320-
// probe pulled (`verdictValue` closes only the dispatch, A31): a
1321-
// `latest()` of a born-into-the-future node inside that pass takes the
1322-
// probe's `joinPass` arm and the pulled pass joins the hold. A verdict
1323-
// read raises no world (F-vworld); the slot — the staging — agrees.
1324-
issue: 4429,
1325-
kind: "world",
1326-
row: "Vl4",
1327-
expected: WORLD_S,
1328-
actual: WORLD_H,
1329-
when: (c, el, seat) =>
1330-
(el!._config & CONFIG_HELD) !== 0 &&
1331-
((el as Computed<any>)._statusFlags & STATUS_UNINITIALIZED) !== 0 &&
1332-
el!._pendingValue !== NOT_PENDING &&
1333-
!((el as Computed<any>)._statusFlags & STATUS_PENDING) &&
1334-
(c._flags & REACTIVE_JOINED) !== 0 &&
1335-
seat.ownWorld < WORLD_H
13361317
}
13371318
];
13381319
const knownHits = new Map<number, number>();

‎packages/signals/src/core/verdict.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,14 +412,20 @@ function verdictValue(el: Signal<any> | Computed<any>, c: Computed<any> | null):
412412
// produced. So is a lazy or dormant memo's wake (an unowned memo with no
413413
// reader left, #4366): woken inside the window, it read its sources as the
414414
// probe does, the committed value under a landing. (A signal has no
415-
// `_flags`: the wake is a no-op for it.)
415+
// `_flags`: the wake is a no-op for it.) So is the probe's state (#4429):
416+
// a `latest()` the pass makes itself does not join a hold through the
417+
// probe's arm, and what the pass reads does not answer the outer probe
418+
// (`probeFound` is only set while `probing`).
419+
const prevProbing = probing;
420+
probing = false;
416421
setVerdict(null);
417422
prepareComputed(owner, false);
418423
if (c && tracking) {
419424
link(el, c);
420425
if ((el as any)._fn) pullComputed(owner, c);
421426
}
422427
setVerdict(verdictValue);
428+
probing = prevProbing;
423429
// Shadow (S0): the window's decision point — the rows Vl*/Vp*.
424430
if (__TEST__) shadowExpect(c, el, tracking, latestActive ? 2 : 1, spectating, false);
425431
// A live `affects()` mark on the node or a dependency (affects.ts): not
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* #4429 — a memo's pass pulled inside a verdict window is its own (A31),
3+
* whoever woke it.
4+
*
5+
* `verdictValue` pulls a dirty memo with the dispatch closed
6+
* (`setVerdict(null)`), so the memo's plain reads are its own. It left the
7+
* probe's state set, though: `probing` stayed on for the pulled pass, so a
8+
* `latest()` the memo calls itself re-entered the window as the outer probe:
9+
*
10+
* - `latest()` of a node born held (no committed value, staged) took the
11+
* probe's `joinPass` arm: the pulled memo joined the hold, and a memo it
12+
* created during that pass was born held (D-27) — where the same pass
13+
* pulled by a plain read leaves it mainline (F-vworld: a verdict read
14+
* raises no world);
15+
* - `probeFound` was set from inside the pulled pass (a flight its
16+
* `latest()` read), so the outer `isPending` answered for the memo's
17+
* reads rather than for the memo.
18+
*
19+
* Each case compares the pull inside the window with the same pass woken by
20+
* a plain read: the memo's pass, its value and what it creates are the same.
21+
*/
22+
import { describe, expect, it } from "vitest";
23+
import {
24+
action,
25+
createMemo,
26+
createRenderEffect,
27+
createRoot,
28+
createSignal,
29+
flush,
30+
isPending,
31+
latest,
32+
untrack
33+
} from "../src/index.js";
34+
35+
const peek = (fn: () => unknown) => {
36+
try {
37+
return untrack(fn);
38+
} catch (e) {
39+
return (e as Error).constructor.name;
40+
}
41+
};
42+
43+
const settle = async () => {
44+
await Promise.resolve();
45+
await Promise.resolve();
46+
flush();
47+
};
48+
49+
describe("#4429: a memo pulled inside a verdict window computes as its own pass", () => {
50+
for (const pull of ["isPending", "plain"] as const)
51+
it(`latest() of a born-held node in the pulled pass joins nothing (pulled ${pull})`, async () => {
52+
const [x, setX] = createSignal(0);
53+
const [s, setS] = createSignal(0);
54+
const [on, setOn] = createSignal(false);
55+
let born: (() => number) | null = null;
56+
let child!: () => number;
57+
const m1 = createRoot(() => {
58+
createRenderEffect(x, () => {});
59+
// Deeper than the reader below: the reader's pass pulls it.
60+
const sa = createMemo(() => s());
61+
const m1 = createMemo(() => {
62+
const v = sa();
63+
const l = born ? latest(born) : -1;
64+
// Reads nothing held: born held only if its creator joined.
65+
child = createMemo(() => v * 100);
66+
return l * 10 + v;
67+
});
68+
createRenderEffect(m1, () => {});
69+
createRenderEffect(
70+
() => on() && (pull === "isPending" ? isPending(() => m1()) : m1()),
71+
() => {}
72+
);
73+
return m1;
74+
});
75+
flush();
76+
let release!: () => void;
77+
action(function* () {
78+
setX(1);
79+
yield new Promise<void>(r => (release = r));
80+
})();
81+
flush();
82+
83+
// Born held: no committed value, its staging (1) its only value.
84+
createRoot(() => {
85+
born = createMemo(() => x());
86+
});
87+
expect(peek(born!)).toBe("NotReadyError");
88+
setS(1);
89+
setOn(true);
90+
flush();
91+
// The pulled pass read `latest(born)` = 1 (D-22) and made its child
92+
// from the screen's s: mainline — not the hold's.
93+
expect(peek(m1)).toBe(11);
94+
expect(peek(child)).toBe(100);
95+
release();
96+
await settle();
97+
expect(peek(m1)).toBe(11);
98+
expect(peek(child)).toBe(100);
99+
});
100+
101+
for (const pull of ["isPending", "plain"] as const)
102+
it(`a flight the pulled pass's latest() reads is not the probe's answer (pulled ${pull})`, async () => {
103+
const [q, setQ] = createSignal(1);
104+
const [s, setS] = createSignal(0);
105+
const [on, setOn] = createSignal(false);
106+
let resolve!: () => void;
107+
const seen: unknown[] = [];
108+
const m1 = createRoot(() => {
109+
const a = createMemo(async () => {
110+
const v = q();
111+
if (v > 1) await new Promise<void>(r => (resolve = r));
112+
return v;
113+
});
114+
createRenderEffect(a, () => {});
115+
const sa = createMemo(() => s());
116+
const m1 = createMemo(() => sa() + latest(a) * 10);
117+
createRenderEffect(m1, () => {});
118+
createRenderEffect(
119+
() => on() && (pull === "isPending" ? isPending(() => m1()) : (m1(), isPending(m1))),
120+
v => {
121+
seen.push(v);
122+
}
123+
);
124+
return m1;
125+
});
126+
await settle();
127+
expect(peek(m1)).toBe(10);
128+
setQ(2);
129+
flush();
130+
// a is in flight over its committed 1.
131+
setS(1);
132+
setOn(true);
133+
flush();
134+
// m1 is a settled sync memo of `latest(a)` — the committed 1 while the
135+
// flight is up: nothing m1 shows is waiting on it.
136+
expect(peek(m1)).toBe(11);
137+
expect(seen).toEqual([false, false]);
138+
resolve();
139+
await settle();
140+
expect(peek(m1)).toBe(21);
141+
});
142+
});

0 commit comments

Comments
 (0)