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/lane-holds-through-boundary-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A lane now holds through a `Loading` boundary showing content: a boundary output forwarding its tree's pending counts as a frame reader, so `latest()` no longer reveals beside the boundary's stale content (A33). An output that never committed — a mount the frame has not revealed — holds nothing. A corrected guess that never showed is void at the correction: untracked reads return the committed value (A18 amendment, 2026-10-05).
90 changes: 45 additions & 45 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/signals/docs/SPEC-ASYNC-SEMANTICS.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CONFIG_GUESS,
CONFIG_INPUTS_PUBLISHED,
CONFIG_OVERRIDE,
CONFIG_REDERIVE,
CONFIG_STAGED,
CONFIG_VERDICT,
EFFECT_RENDER,
Expand Down Expand Up @@ -313,9 +314,16 @@ function blockedBy(nodes: Signal<any>[], owner: Transaction, own = false): boole
// `frameRead`): served committed instead of going pending, it derives
// from the flight all the same and is re-derived at the landing — the
// frame's observation survives its reader becoming a lane's (a guess
// over a held window, V5/A17). A probe alone does not hold.
// over a held window, V5/A17). A probe alone does not hold. A
// boundary showing content forwards the pending (its output pending,
// CONFIG_REDERIVE): transparent, its readers hold through it (A33; the
// lane-membership ruling, 2026-10-05). An output that never committed
// shows nothing — a mount the frame has not revealed — and holds
// nothing.
if (
((r as any)._type === EFFECT_RENDER || r._flags & REACTIVE_FRAME_READ) &&
((r as any)._type === EFFECT_RENDER ||
r._flags & REACTIVE_FRAME_READ ||
(r._config & CONFIG_REDERIVE && r._statusFlags === STATUS_PENDING)) &&
(s._gen === r._depGen || r._x?._error != null) &&
!(r._flags & REACTIVE_DISPOSED) &&
onScreen(r, judge ?? owner)
Expand Down
134 changes: 134 additions & 0 deletions packages/signals/tests/boundary-output-frame-reader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// F6 (fuzz-findings-l2.test.ts) counts a boundary output forwarding its
// tree's pending as a frame reader of the lane. Its limits: an output that
// never committed (a mount the action staged, never shown) holds nothing —
// counting it strands the lane and the action's last write never publishes
// (fuzzer seed 91501 `latest` #138); a withdrawn mount's output is a zombie
// whose removal the action stages (A15); and nothing outlives a failed action.
import { expect, it } from "vitest";
import {
action,
createLoadingBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
latest,
onCleanup
} from "../src/index.js";

const tick = () => new Promise<void>(r => setTimeout(r, 0));
async function drain(n = 4) {
for (let i = 0; i < n; i++) {
await tick();
flush();
}
}

function setup(fail = false) {
const [s, setS] = createSignal(0);
const [mounted, setMounted] = createSignal(true);
const view = { out: "absent" as unknown };
let resume!: () => void;
let run!: () => Promise<void>;
let dispose!: () => void;
createRoot(d => {
dispose = d;
const node0 = createMemo(() => Promise.resolve(latest(s)));
createRenderEffect(
() =>
mounted()
? createRoot(dd => {
const b = createLoadingBoundary(
() => node0(),
() => "loading"
);
createRenderEffect(b, v => {
view.out = v;
});
onCleanup(() => {
view.out = "absent";
});
return dd;
})
: undefined,
dd => {
if (dd) onCleanup(dd);
}
);
run = action(function* () {
setS(0);
yield new Promise<void>(r => (resume = r));
if (fail) throw new Error("cancelled");
setS(1);
});
});
return { s, setS, setMounted, view, run, resume: () => resume(), dispose };
}

it("an action's mount withdraw + restore over a Loading forwarding latest() completes", async () => {
const t = setup();
flush();
await drain();
const p = t.run();
t.setMounted(false);
flush();
await drain();
t.setMounted(true);
flush();
await drain();
t.resume();
await p;
await drain(8);
expect([t.view.out, t.s()]).toEqual([1, 1]);
t.dispose();
});

it("an action's mount withdraw, not restored, completes with the mount gone", async () => {
const t = setup();
flush();
await drain();
const p = t.run();
t.setMounted(false);
flush();
await drain();
t.resume();
await p;
await drain(8);
expect([t.view.out, t.s()]).toEqual(["absent", 1]);
t.setMounted(true);
flush();
await drain(8);
expect([t.view.out, t.s()]).toEqual([1, 1]);
t.dispose();
});

for (const restore of [false, true]) {
it(`an action failing after staging a mount withdraw${restore ? " + restore" : ""} leaves nothing held`, async () => {
const t = setup(true);
flush();
await drain();
const p = t.run().catch(e => e);
t.setMounted(false);
flush();
await drain();
if (restore) {
t.setMounted(true);
flush();
await drain();
}
t.resume();
expect(await p).toBeInstanceOf(Error);
await drain(8);
t.setMounted(true);
t.setS(7);
flush();
await drain(8);
expect([t.view.out, t.s()]).toEqual([7, 7]);
t.setMounted(false);
flush();
await drain(8);
expect(t.view.out).toBe("absent");
t.dispose();
});
}
110 changes: 56 additions & 54 deletions packages/signals/tests/fuzz-findings-l2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,63 +505,65 @@ describe("fuzz findings on L2 — lanes", () => {
// boundary's tree is the subscriber and the blocker predicate sees no frame
// reader: the lane is judged unblocked, `latest=1` shows beside
// `details=0`, and when `details(1)` lands it never displays — the stale
// frame stays until the action ends.
it.fails(
"F6: a retaining boundary between a lane's derivation and its reader does not unblock the lane (A17)",
async () => {
const [$id, setId] = createSignal(0);
const gates = new Map<string, () => void>();
let resume!: () => void;
let run!: () => Promise<void>;
let L = 0;
let D: unknown = "?";
const frames: string[] = [];
let dispose!: () => void;
createRoot(d => {
dispose = d;
const details = createMemo(() => {
const id = latest($id);
return gated(gates, `d:${id}`, id);
});
createRenderEffect(
() => latest($id),
v => {
L = v;
frames.push(`L=${L} D=${D}`);
}
);
const view = createLoadingBoundary(details, () => "loading" as const);
createRenderEffect(view, v => {
D = v;
// frame stays until the action ends. A33/B5 and the lane-membership ruling
// (2026-10-05, reading A): a boundary showing content is transparent — the
// lane holds through it. A boundary output forwarding its tree's pending
// (CONFIG_REDERIVE, itself pending) now counts as a frame reader in
// `blockedBy`'s one hop over the pending tree; one showing its fallback is
// not pending and still holds nothing.
it("F6: a lane holds through a Loading boundary showing content, revealing with the derivation under it (A17, A33)", async () => {
const [$id, setId] = createSignal(0);
const gates = new Map<string, () => void>();
let resume!: () => void;
let run!: () => Promise<void>;
let L = 0;
let D: unknown = "?";
const frames: string[] = [];
let dispose!: () => void;
createRoot(d => {
dispose = d;
const details = createMemo(() => {
const id = latest($id);
return gated(gates, `d:${id}`, id);
});
createRenderEffect(
() => latest($id),
v => {
L = v;
frames.push(`L=${L} D=${D}`);
}
);
const view = createLoadingBoundary(details, () => "loading" as const);
createRenderEffect(view, v => {
D = v;
frames.push(`L=${L} D=${D}`);
});
run = action(function* () {
setId(1);
yield new Promise<void>(r => {
resume = r;
});
run = action(function* () {
setId(1);
yield new Promise<void>(r => {
resume = r;
});
setId(2);
});
setId(2);
});
flush();
gates.get("d:0")!();
await drain();
expect([L, D]).toEqual([0, 0]);
const p = run();
await drain();
// The lane is held by details' flight: no frame shows latest=1 alone.
expect([L, D]).toEqual([0, 0]);
gates.get("d:1")!();
await drain();
// The derivation landed: the lane reveals both.
expect([L, D]).toEqual([1, 1]);
resume();
gates.get("d:2")?.();
await p;
await drain();
dispose();
}
);
});
flush();
gates.get("d:0")!();
await drain();
expect([L, D]).toEqual([0, 0]);
const p = run();
await drain();
// The lane is held by details' flight: no frame shows latest=1 alone.
expect([L, D]).toEqual([0, 0]);
gates.get("d:1")!();
await drain();
// The derivation landed: the lane reveals both.
expect([L, D]).toEqual([1, 1]);
resume();
gates.get("d:2")?.();
await p;
await drain();
dispose();
});

// F8. A15 lanes corollary (#3460): "a render effect OFF the lane that reads
// what the lane is revealing … shows the committed value, publishes now,
Expand Down
12 changes: 8 additions & 4 deletions packages/signals/tests/spec-async-semantics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,7 @@ describe("A18 (was B4): an override's lifetime is bound to its own async source,
return g;
}

it("differing arrival: downstream async restarts from the truth immediately; screen and untracked reads keep the override until commit", async () => {
it("differing arrival: downstream async restarts from the truth immediately; screen and untracked reads keep what showed until commit", async () => {
const g = await primed();

g.setValue(1);
Expand All @@ -1086,15 +1086,19 @@ describe("A18 (was B4): an override's lifetime is bound to its own async source,
await settle();
expect(g.flights.map(f => f.n)).toEqual([3, 2]);
expect(latest(g.double)).toBe(2); // the arrived truth
expect(g.double()).toBe(3); // untracked read: still the displayed override
// Untracked read: what the screen shows. The guess never revealed (the
// lane held on its downstream flight through the boundary), so it is
// void at the correction and the committed value stays (A18
// amendment, 2026-10-05: correction display is what showed).
expect(g.double()).toBe(0);
expect(isPending(g.double)).toBe(true); // displayed ≠ final
expect(g.log).toEqual([]);

// The superseded 3-flight landing changes nothing — it is not the truth.
g.flights.shift()!.resolve();
await settle();
expect(g.log).toEqual([]);
expect(g.double()).toBe(3);
expect(g.double()).toBe(0);

// The 2-flight lands: the transaction commits, the optimism is gone.
g.flights.shift()!.resolve();
Expand Down Expand Up @@ -1172,7 +1176,7 @@ describe("A18 (was B4): an override's lifetime is bound to its own async source,
await settle();
expect(flights.map(f => f.n)).toEqual([3, 2]);
expect(latest(double)).toBe(2);
expect(double()).toBe(3);
expect(double()).toBe(0); // the never-shown guess is void (A18 amendment, 2026-10-05)
expect(isPending(double)).toBe(true);
expect(log).toEqual([]);

Expand Down
6 changes: 4 additions & 2 deletions packages/signals/tests/store/lane-authority-twins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,16 @@ describe("store twins of the lane-authority fixes (#3335/#3334/#3330/#3331)", ()
await settle();
expect(flights.map(f => f.n)).toEqual([3, 2]); // graph moves to 2 NOW
expect(latest(() => state.d)).toBe(2);
expect(state.d).toBe(3);
// The guess never showed (held on its downstream flight through the
// boundary): void at the correction (A18 amendment, 2026-10-05).
expect(state.d).toBe(0);
expect(isPending(() => state.d)).toBe(true);
expect(log).toEqual([]);

flights.shift()!.resolve(); // superseded 3-flight: nothing
await settle();
expect(log).toEqual([]);
expect(state.d).toBe(3);
expect(state.d).toBe(0);

flights.shift()!.resolve();
await settle();
Expand Down
Loading