Skip to content

Commit 7ea8706

Browse files
ryansolidclaude
andauthored
fix(signals): tracked effect re-runs for a memo's same-pass write and a held landing (#4606, #4607) (#4615)
* fix(signals): tracked effect re-runs for a memo's same-pass staging and an action's landing (#4606, #4607) A tracked effect reads committed values (D-29) and links in the effect phase, so a staged value that commits without notifying it leaves its last run stale (F-1). #4602 covered a signal written before the run. Two gaps remained in the same post-run check: - #4606: a memo the run's own read pulls stages while the run recomputes (`read` links, then pulls), so its subscriber walk is refused; the `_notifiedAt <= epoch` test excluded it. A staged memo dependency now re-queues the run; a signal written since the epoch is still the run's own write (#3006). - #4607: a run during a hold, for another dependency, is served the held dependency's committed value and consumes the run the landing replays. The run now registers as A15's stale reader of that hold, re-derived at the landing. `blockedBy` excludes tracked effects from the stale readers that hold a flight: effect-phase code never holds (D-29). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE * fix(signals): queue a tracked effect's landing run off the floor path (#4607) The previous commit registered a tracked effect that read a held value as A15's stale reader (REACTIVE_FRAME_READ) and taught `blockedBy` not to let it hold a flight (D-29). That check sits on the render/createEffect floor and cost +9 B minified there. The tracked effect now queues one run with the hold's stashed runs instead, from `trackedEffect` alone: a lazily made `land` callback, deduped by a per-effect `owed` flag that each run clears, so a run that no longer reads a held value owes nothing at the landing. It never sets FRAME_READ, so it never holds the transaction, and `blockedBy` is back to its base form. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE * perf(signals): keep trackedEffect's early exit and stop at the first re-queue Return before the dep walk when nothing is staged and no transaction is live; break after enqueueSub, since the re-run re-checks every dep. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE * perf(signals): drop trackedEffect's early exit, keep the break The early exit cost +33-40 B minified on the router rows; the dep walk is a few field reads per run with no allocation. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --------- Co-authored-by: Claude <[email protected]>
1 parent 2ba429b commit 7ea8706

5 files changed

Lines changed: 247 additions & 9 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 `createTrackedEffect` now re-runs for a same-pass write it reads through a memo (#4606), and for an action's landing when it re-ran during the transition for another dependency (#4607). A memo the run's own read pulls stages its value while the run recomputes, so its subscriber walk woke nobody; the run now re-queues for any memo dependency staged with a different value, as it does for a signal written before the run (#4598). A run during a hold is served the committed value of the held dependency and had consumed the run the landing replays; it now queues one run with that hold's stashed runs, so it re-runs at the landing as a stale reader would (A15), without holding the transaction open (D-29). A tracked effect that writes a signal and then reads a memo of it re-runs once the write commits, as one that reads the memo first already did.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
5959
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | 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… |
6060
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | 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 … |
6161
| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | — | spec-async-semantics.test.ts×1 | [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… |
62-
| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×2 constants.ts×1 core.ts×4 lanes.ts×6 scheduler.ts×16 seat.ts×1 verdict.ts×4 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-shortcut-pending-source-liveness.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [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 … |
62+
| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×2 constants.ts×1 core.ts×4 effect.ts×1 lanes.ts×6 scheduler.ts×16 seat.ts×1 verdict.ts×4 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 tracked-effect-staged-dep-4606-4607.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-shortcut-pending-source-liveness.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [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 … |
6363
| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:223` | scheduler.ts×1 | caught-reactive-read-diagnostic.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 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… |
6464
| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 seat.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.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… |
6565
| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.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… |

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

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
haltReactivity,
4242
haltReactivityAt,
4343
notifyEpoch,
44-
pendingNodes,
44+
txOf,
4545
resetUnhandledAsync,
4646
schedule,
4747
setTrackedQueueCallback,
@@ -295,6 +295,11 @@ export interface TrackedEffect extends Computed<void> {
295295
* Uses stale reads.
296296
*/
297297
export function trackedEffect(fn: () => void | (() => void), options?: NodeOptions<any>): void {
298+
// A run owed at a hold's landing (#4607): `land`, made on first need, is
299+
// queued with the hold's stashed runs; `owed` dedups it, and a later run
300+
// that reads nothing held clears it.
301+
let owed = false,
302+
land: (() => void) | undefined;
298303
const run = () => {
299304
// `_modified` is NOT redundant with the heap: the heap dedups within a
300305
// pass, but several passes in one flush each enqueue `_run` into the same
@@ -303,25 +308,51 @@ export function trackedEffect(fn: () => void | (() => void), options?: NodeOptio
303308
if (heldRunHook?.(node)) return;
304309
if (__DEV__) setTrackedQueueCallback(true);
305310
const epoch = notifyEpoch;
311+
owed = false;
306312
try {
307313
node._modified = false;
308314
recompute(node);
309315
} finally {
310316
if (__DEV__) setTrackedQueueCallback(false);
311317
}
312-
if (pendingNodes.length === 0) return;
313-
// A dep staged before this run linked it commits without a notify.
318+
// A dep this run was served the committed value of (D-29) while a
319+
// different value is staged commits without a notify to it: its
320+
// subscriber walk predates the link. Re-run where that value commits
321+
// (F-1). Writes that net to the committed value stage no change.
314322
for (let d = node._deps; d !== null; d = d._nextDep) {
315323
const dep = d._dep;
316324
if (
317325
dep._pendingValue !== NOT_PENDING &&
318-
dep._notifiedAt <= epoch &&
319-
!(dep._config & (CONFIG_HELD | CONFIG_OVERRIDE)) &&
326+
!(dep._config & CONFIG_OVERRIDE) &&
320327
(dep._pendingValue !== dep._value || !Object.is(dep._pendingValue, dep._value))
321328
) {
322-
enqueueSub(node);
323-
schedule();
324-
break;
329+
// Held: it commits at its hold's landing, which runs the hold's
330+
// stashed runs — but a run during the hold, for another dep, had
331+
// consumed the one queued for it (#4607). Re-run there, as A15's
332+
// stale reader would be; effect-phase code holds nothing (D-29).
333+
if (dep._config & CONFIG_HELD) {
334+
if (!owed) {
335+
owed = true;
336+
txOf(dep)._queues[EFFECT_USER - 1].push(
337+
(land ||= () => {
338+
if (owed) {
339+
node._modified = true;
340+
run();
341+
}
342+
})
343+
);
344+
}
345+
}
346+
// Staged this flush: the next round commits it. A memo is staged
347+
// by this run's own pull (`read` links, then pulls), whose walk
348+
// the recomputing run refuses (#4606); a signal written since the
349+
// epoch is the run's own write, never observed (#3006).
350+
else if (dep._notifiedAt <= epoch || (dep as Computed<unknown>)._fn) {
351+
enqueueSub(node);
352+
schedule();
353+
// The re-run re-checks every dep, held ones included.
354+
break;
355+
}
325356
}
326357
}
327358
};
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { afterEach, expect, it } from "vitest";
2+
import {
3+
action,
4+
createEffect,
5+
createMemo,
6+
createRenderEffect,
7+
createRoot,
8+
createSignal,
9+
createTrackedEffect,
10+
flush,
11+
onSettled
12+
} from "../src/index.js";
13+
14+
afterEach(() => flush());
15+
16+
async function settle() {
17+
for (let i = 0; i < 6; i++) {
18+
flush();
19+
await Promise.resolve();
20+
}
21+
flush();
22+
}
23+
24+
// #4606: the run's own read pulls the memo, whose walk the recomputing run
25+
// refuses; the tracked effect re-runs once the memo's value commits (F-1).
26+
it("re-runs for a same-pass write it reads through a memo (#4606)", async () => {
27+
const log: number[] = [];
28+
createRoot(() => {
29+
const [s, setS] = createSignal(0);
30+
const m = createMemo(() => s() * 10);
31+
createEffect(
32+
() => 1,
33+
() => {
34+
setS(1);
35+
}
36+
);
37+
createTrackedEffect(() => {
38+
log.push(m());
39+
});
40+
});
41+
await settle();
42+
expect(log).toEqual([0, 10]);
43+
});
44+
45+
it("re-runs for an onSettled write it reads through a memo (#4606)", async () => {
46+
const log: number[] = [];
47+
createRoot(() => {
48+
const [s, setS] = createSignal(100);
49+
const m = createMemo(() => s() + 1);
50+
onSettled(() => {
51+
setS(101);
52+
});
53+
createTrackedEffect(() => {
54+
log.push(m());
55+
});
56+
});
57+
await settle();
58+
expect(log).toEqual([101, 102]);
59+
});
60+
61+
it("does not re-run for a memo whose same-pass writes net to the committed value", async () => {
62+
const log: number[] = [];
63+
createRoot(() => {
64+
const [s, setS] = createSignal(0);
65+
const m = createMemo(() => s() * 10);
66+
createEffect(
67+
() => 1,
68+
() => {
69+
setS(1);
70+
setS(0);
71+
}
72+
);
73+
createTrackedEffect(() => {
74+
log.push(m());
75+
});
76+
});
77+
await settle();
78+
expect(log).toEqual([0]);
79+
});
80+
81+
// #4607: a run during an action's hold, for another dependency, is served
82+
// the committed value of the held one (D-29) and consumes the run the
83+
// landing replays; it re-runs at the landing as A15's stale reader.
84+
for (const mode of ["tracked", "effect"] as const)
85+
for (const touchU of [false, true])
86+
it(`${mode}: last run sees an action's landing (mid-hold run: ${touchU}) (#4607)`, async () => {
87+
const log: string[] = [];
88+
let release!: (v?: unknown) => void;
89+
let s!: () => number, setS!: (v: number) => void, setU!: (v: number) => void;
90+
createRoot(() => {
91+
let u: () => number;
92+
[s, setS] = createSignal(0);
93+
[u, setU] = createSignal(0);
94+
if (mode === "tracked")
95+
createTrackedEffect(() => {
96+
log.push(`s${s()}u${u()}`);
97+
});
98+
else
99+
createEffect(
100+
() => `s${s()}u${u()}`,
101+
v => {
102+
log.push(v);
103+
}
104+
);
105+
});
106+
await settle();
107+
const p = new Promise(r => (release = r));
108+
const done = action(function* () {
109+
setS(1);
110+
yield p;
111+
})();
112+
await settle();
113+
if (touchU) {
114+
setU(1);
115+
await settle();
116+
// D-29: the held write is never visible to it.
117+
expect(log[log.length - 1]).toBe(mode === "tracked" ? "s0u1" : "s0u0");
118+
}
119+
release();
120+
await done;
121+
await settle();
122+
expect(s()).toBe(1);
123+
expect(log).toEqual(
124+
touchU
125+
? mode === "tracked"
126+
? ["s0u0", "s0u1", "s1u1"]
127+
: ["s0u0", "s1u1"]
128+
: ["s0u0", "s1u0"]
129+
);
130+
});
131+
132+
// D-29: re-run at the landing, but it holds nothing — a flight it reads,
133+
// once no render effect reads it, does not keep the hold open.
134+
it("a tracked effect that read a held value does not hold a flight (#4607)", async () => {
135+
const shown: string[] = [];
136+
const seen: number[] = [];
137+
let s!: () => number, setS!: (v: number) => void;
138+
let setU!: (v: number) => void, setShow!: (v: boolean) => void;
139+
createRoot(() => {
140+
let u: () => number, show: () => boolean;
141+
[s, setS] = createSignal(0);
142+
[u, setU] = createSignal(0);
143+
[show, setShow] = createSignal(true);
144+
const F = createMemo(async () => (s() === 0 ? 0 : new Promise<number>(() => {})));
145+
createRenderEffect(
146+
() => (show() ? `s${s()} F${F()}` : `s${s()} hidden`),
147+
v => {
148+
shown.push(v);
149+
}
150+
);
151+
createTrackedEffect(() => {
152+
u();
153+
seen.push(s());
154+
F();
155+
});
156+
});
157+
await settle();
158+
setS(1);
159+
await settle();
160+
setU(1);
161+
await settle();
162+
expect(seen).toEqual([0, 0]);
163+
setShow(false);
164+
await settle();
165+
expect(shown).toEqual(["s0 F0", "s1 hidden"]);
166+
expect(seen).toEqual([0, 0, 1]);
167+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @jsxImportSource @solidjs/web
3+
* @vitest-environment jsdom
4+
*/
5+
import { expect, it } from "vitest";
6+
import { createMemo, createSignal, createTrackedEffect, flush, onSettled } from "solid-js";
7+
import { render } from "../src/index.js";
8+
9+
async function settle() {
10+
flush();
11+
await Promise.resolve();
12+
flush();
13+
}
14+
15+
it("a tracked effect re-runs for a prop the parent's onSettled writes, read through a memo (#4606)", async () => {
16+
const log: number[] = [];
17+
function Child(props: { v: number }) {
18+
const m = createMemo(() => props.v + 1);
19+
createTrackedEffect(() => {
20+
log.push(m());
21+
});
22+
return null;
23+
}
24+
function Parent() {
25+
const [s, setS] = createSignal(99);
26+
onSettled(() => {
27+
setS(100);
28+
});
29+
return <Child v={s()} />;
30+
}
31+
const dispose = render(() => <Parent />, document.createElement("div"));
32+
await settle();
33+
expect(log).toEqual([100, 101]);
34+
dispose();
35+
});

0 commit comments

Comments
 (0)