Skip to content

Commit 39f951b

Browse files
ryansolidclaude
andauthored
fix(signals): a latest() reader queued to re-derive keeps a fresh Loading waiting (#4568) (#4578)
* fix(signals): a latest() reader queued to re-derive keeps a fresh Loading waiting (#4568) A verdict reader whose source settled is queued to re-derive with the pending it carried retired (A19). A sibling settling judged the boundary's readers by status before that pass ran, so a fresh <Loading> revealed into a hold the reader's pass then went pending in. A later rejection was staged in that hold (D-41) and reached <Errored> only when the obsolete flight landed. prune now keeps a verdict reader still in the heap. Fixes #4568. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 * docs(signals): regenerate RULES-INDEX for #4568's A19 citations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f509fe3 commit 39f951b

5 files changed

Lines changed: 176 additions & 3 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 rejection under a `<Loading>` that has not shown its content reaches `<Errored>` at once when a `latest()` reader under the same boundary reads a derivation whose upstream still has a flight out for an older question (#4568). The boundary judged its readers settled while that `latest()` reader was queued to re-derive, revealed into a hold the reader then went pending in, and the later rejection was staged in that hold until the obsolete flight landed. A verdict reader queued to re-derive is now waited on until its pass answers (A19, D-41).

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
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… |
66-
| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | async.ts×1 constants.ts×1 core.ts×6 lanes.ts×1 scheduler.ts×2 seat.ts×1 types.ts×1 verdict.ts×6 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 verdict-memo-flight-no-reask.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… |
66+
| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | boundaries.ts×1 async.ts×1 constants.ts×1 core.ts×6 lanes.ts×1 scheduler.ts×2 seat.ts×1 types.ts×1 verdict.ts×6 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 errored-latest-obsolete-flight-4568.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 verdict-memo-flight-no-reask.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… |
6767
| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:410` | — | 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… |
6868
| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:417` | — | 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… |
6969
| A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:231` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 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) … |

‎packages/signals/src/boundaries.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
EFFECT_TRACKED,
5252
EFFECT_USER,
5353
REACTIVE_DISPOSED,
54+
REACTIVE_IN_HEAP,
5455
REACTIVE_LANE_READ,
5556
REACTIVE_PROBED,
5657
REACTIVE_ZOMBIE,
@@ -356,10 +357,15 @@ export function prune(b: Boundary, pass?: boolean | 0): number {
356357
// An error staged on its source (D-41, `stageError`) is not settled:
357358
// it publishes with the commit. A reader that threw it now (a lane
358359
// pass, a reader of a node born staged) reached the boundary first
359-
// (#4364).
360+
// (#4364). Nor a verdict reader queued to re-derive: its source
361+
// settling retired the pending it carried, and its pass, not that
362+
// retirement, is its answer (A19) — it may ask the next flight. Judged
363+
// settled before it ran, a fresh boundary revealed into a hold its
364+
// pass then went pending in, which waited on that flight (#4568).
360365
if (
361366
(r._statusFlags | s._statusFlags) & mask ||
362-
(mask & STATUS_ERROR && s._config & CONFIG_STAGED_ERROR)
367+
(mask & STATUS_ERROR && s._config & CONFIG_STAGED_ERROR) ||
368+
(r._config & CONFIG_VERDICT && r._flags & REACTIVE_IN_HEAP)
363369
) {
364370
if (pass === 0) break;
365371
continue;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { expect, it } from "vitest";
2+
import {
3+
createErrorBoundary,
4+
createLoadingBoundary,
5+
createMemo,
6+
createRenderEffect,
7+
createRoot,
8+
createSignal,
9+
flush,
10+
latest
11+
} from "../src/index.js";
12+
13+
// #4568: a `latest()` reader under a fresh <Loading> whose source settled was
14+
// queued to re-derive (A19) with the pending it carried retired. A sibling
15+
// settling judged the boundary's readers by status before that pass ran, and
16+
// the boundary revealed into a hold the reader's pass then went pending in —
17+
// a hold on the next flight. The rejection that came after was staged in it
18+
// (D-41) and reached <Errored> only when that obsolete flight landed.
19+
20+
const tick = async () => {
21+
for (let i = 0; i < 2; i++) {
22+
await new Promise(r => setTimeout(r, 0));
23+
flush();
24+
}
25+
};
26+
27+
function run(mode: "plain" | "latest") {
28+
const flights = new Map<string, { resolve: (v: number) => void; reject: (e: Error) => void }>();
29+
const ask = (key: string) =>
30+
new Promise<number>((resolve, reject) => flights.set(key, { resolve, reject }));
31+
const [s, setS] = createSignal(0);
32+
let out = "";
33+
const dispose = createRoot(dispose => {
34+
const a0 = createMemo(() => ask("a0:" + s()));
35+
const a1 = createMemo(async () => ask("a1:" + (await a0())));
36+
const c0 = createMemo(() => `${a1()}.${a0()}`);
37+
const view = createErrorBoundary(
38+
() =>
39+
createLoadingBoundary(
40+
() => {
41+
createRenderEffect(
42+
() => a0(),
43+
() => {}
44+
);
45+
createRenderEffect(
46+
() => (mode === "latest" ? latest(c0) : c0()),
47+
() => {}
48+
);
49+
return "content";
50+
},
51+
() => "[L]"
52+
),
53+
(e: () => any) => `[E ${e()?.message}]`
54+
);
55+
createRenderEffect(
56+
() => {
57+
const v = view();
58+
return typeof v === "function" ? (v as () => unknown)() : v;
59+
},
60+
v => void (out = String(v))
61+
);
62+
return dispose;
63+
});
64+
return { flights, setS, out: () => out, dispose };
65+
}
66+
67+
for (const mode of ["plain", "latest"] as const)
68+
it(`a rejection under a fresh Loading reaches Errored without waiting on an obsolete flight (${mode})`, async () => {
69+
const { flights, setS, out, dispose } = run(mode);
70+
await tick();
71+
setS(1);
72+
flush();
73+
await tick();
74+
flights.get("a0:1")!.resolve(12);
75+
await tick();
76+
// Nothing under the fresh boundary holds: the fallback stays.
77+
expect(out()).toBe("[L]");
78+
setS(2);
79+
flush();
80+
await tick();
81+
flights.get("a0:2")!.reject(new Error("boom"));
82+
for (let i = 0; i < 3; i++) await tick();
83+
const afterReject = out();
84+
flights.get("a1:12")!.resolve(7);
85+
await tick();
86+
const afterObsolete = out();
87+
dispose();
88+
expect({ afterReject, afterObsolete }).toEqual({
89+
afterReject: "[E boom]",
90+
afterObsolete: "[E boom]"
91+
});
92+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @jsxImportSource @solidjs/web
3+
*/
4+
// #4568: under a `<Loading>` that has not revealed yet, a rejection reaches
5+
// `<Errored>` at once even when a `latest()` reader under the same boundary
6+
// reads a derive whose upstream still has a flight out for an older question.
7+
// An error is an answer: it ends the wait like a value (F-12, D-41), and the
8+
// obsolete flight answers a question no reader asks anymore.
9+
import { render } from "@solidjs/web";
10+
import { createMemo, createSignal, Errored, flush, latest, Loading } from "solid-js";
11+
import { describe, expect, it } from "vitest";
12+
13+
const tick = async () => {
14+
for (let i = 0; i < 2; i++) {
15+
await new Promise(r => setTimeout(r, 0));
16+
flush();
17+
}
18+
};
19+
20+
async function run(mode: "latest" | "plain", direct = true) {
21+
const flights = new Map<string, { resolve: (v: number) => void; reject: (e: Error) => void }>();
22+
const ask = (key: string) =>
23+
new Promise<number>((resolve, reject) => flights.set(key, { resolve, reject }));
24+
const [s, setS] = createSignal(0);
25+
const el = document.createElement("div");
26+
const dispose = render(() => {
27+
const a0 = createMemo(() => ask("a0:" + s()));
28+
const a1 = createMemo(async () => ask("a1:" + (await a0())));
29+
const c0 = createMemo(() => `${a1()}.${a0()}`);
30+
return (
31+
<Errored fallback={e => <p>error: {(e() as Error).message}</p>}>
32+
<Loading fallback={<p>loading</p>}>
33+
{direct ? <p>a0={a0()}</p> : null}
34+
<i>c0={mode === "latest" ? latest(c0) : c0()}</i>
35+
</Loading>
36+
</Errored>
37+
);
38+
}, el);
39+
await tick();
40+
setS(1);
41+
flush();
42+
await tick();
43+
flights.get("a0:1")!.resolve(12);
44+
await tick(); // asks a1:12
45+
setS(2);
46+
flush();
47+
await tick(); // asks a0:2; a1:12 is now obsolete
48+
flights.get("a0:2")!.reject(new Error("boom"));
49+
for (let i = 0; i < 6; i++) await tick();
50+
const afterReject = el.textContent;
51+
flights.get("a1:12")!.resolve(7);
52+
await tick(); // the obsolete flight lands
53+
const afterObsolete = el.textContent;
54+
dispose();
55+
return { afterReject, afterObsolete };
56+
}
57+
58+
const both = { afterReject: "error: boom", afterObsolete: "error: boom" };
59+
60+
describe("#4568: a rejection under an unrevealed <Loading> is not held by an obsolete flight", () => {
61+
it("plain c0(): the rejection reaches <Errored> at once", async () => {
62+
expect(await run("plain")).toEqual(both);
63+
});
64+
it("latest(c0): the rejection reaches <Errored> at once", async () => {
65+
expect(await run("latest")).toEqual(both);
66+
});
67+
it("latest(c0) without the direct a0 reader: the rejection reaches <Errored> at once", async () => {
68+
expect(await run("latest", false)).toEqual(both);
69+
});
70+
});

0 commit comments

Comments
 (0)