Skip to content
Open
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-leaf-landing-seat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A render effect that reads a memo over `isPending(source)` and then a second async memo mounts once both first flights land (#3766). The effect went pending as work of the verdict lane, and the landing re-ran it outside the lane, where the uninitialized probe memo had nothing to show: it threw with no source to wake it, and the lane stayed blocked on its own reader. A leaf whose own pass in a lane ends pending now runs its next pass as that lane's. A held render effect that a lane took over and whose pass then leaves the lane goes back to the transaction that held it, instead of keeping its held flag with no transaction (a `null` `_into` crash the semantic fuzzer found).
10 changes: 9 additions & 1 deletion packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
REACTIVE_FRAME_READ,
REACTIVE_IN_HEAP,
REACTIVE_JOINED,
REACTIVE_LANE_DIRTY,
REACTIVE_LANE_READ,
REACTIVE_PROBE_UNANSWERED,
REACTIVE_SCREEN_READ,
Expand Down Expand Up @@ -305,7 +306,12 @@ export function laneStage(
): boolean {
if (!create && !(el._flags & REACTIVE_LANE_READ) && !(el._config & CONFIG_GUESS)) {
if (el._x !== null) {
if (el._x._transaction?._lane) el._x._transaction = null;
const t = el._x._transaction;
if (t?._lane) {
el._x._transaction = null;
// A held node the lane took over goes back to the lane's holder.
if (el._config & CONFIG_HELD) list(el, resolveTx(t._parent!));
}
el._x._lane = NOT_PENDING;
}
el._config &= ~CONFIG_OVERRIDE;
Expand All @@ -325,6 +331,8 @@ export function laneStage(
// leaves the guess.
if (!errored) el._config &= ~CONFIG_GUESS;
}
// A leaf has no lane value to seat it: its next pass is the lane's (#3766).
else if (errored && !create) el._flags |= REACTIVE_LANE_DIRTY;
list(el, l);
return true;
}
Expand Down
93 changes: 45 additions & 48 deletions packages/signals/tests/fuzz-findings-l2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,58 +324,55 @@ describe("fuzz findings on L2 — lanes", () => {
// has shown re-asks the derivation chain; the lane should hold its readers
// on the shown frame until the new derivation lands. Instead the reader
// publishes the new guess beside the previous guess's derivation.
it.fails(
"F5: a second guess on a shown lane does not tear against the first guess's derivation (A17)",
async () => {
const [source, setSource] = createSignal(0);
const frames: string[] = [];
let resume!: () => void;
let run!: () => Promise<void>;
let dispose!: () => void;
createRoot(d => {
dispose = d;
const [view, setView] = createOptimistic(() => source());
const d0 = createMemo(() => Promise.resolve(view()));
const d1 = createMemo(() => {
const v = d0();
return Promise.resolve(v);
it("F5: a second guess on a shown lane does not tear against the first guess's derivation (A17)", async () => {
const [source, setSource] = createSignal(0);
const frames: string[] = [];
let resume!: () => void;
let run!: () => Promise<void>;
let dispose!: () => void;
createRoot(d => {
dispose = d;
const [view, setView] = createOptimistic(() => source());
const d0 = createMemo(() => Promise.resolve(view()));
const d1 = createMemo(() => {
const v = d0();
return Promise.resolve(v);
});
createRenderEffect(
() => [view(), d1()],
t => {
frames.push(t.join(","));
}
);
run = action(function* () {
setView(1);
yield new Promise<void>(r => {
resume = r;
});
createRenderEffect(
() => [view(), d1()],
t => {
frames.push(t.join(","));
}
);
run = action(function* () {
setView(1);
yield new Promise<void>(r => {
resume = r;
});
setView(2);
yield new Promise<void>(r => {
resume = r;
});
setSource(0);
setView(2);
yield new Promise<void>(r => {
resume = r;
});
setSource(0);
});
await drain();
expect(frames).toEqual(["0,0"]);
const p = run();
await drain(6);
expect(frames).toEqual(["0,0", "1,1"]);
resume(); // second guess: 2
await drain(6);
// Every delivered frame is coherent: d1 derived from the view beside it.
for (const f of frames) {
const [v, d] = f.split(",");
expect(d, `frame ${f}`).toBe(v);
}
resume();
await p;
await drain(6);
dispose();
});
await drain();
expect(frames).toEqual(["0,0"]);
const p = run();
await drain(6);
expect(frames).toEqual(["0,0", "1,1"]);
resume(); // second guess: 2
await drain(6);
// Every delivered frame is coherent: d1 derived from the view beside it.
for (const f of frames) {
const [v, d] = f.split(",");
expect(d, `frame ${f}`).toBe(v);
}
);
resume();
await p;
await drain(6);
dispose();
});

// F6. A17 (L2): "a derivation of a guess with a flight up holds the lane
// (`lanesBlocked`); `laneSeam` parks a blocked lane's runs". Without a
Expand Down
132 changes: 132 additions & 0 deletions packages/signals/tests/ispending-memo-gate-3766.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending
} from "../src/index.js";

const delay = <T>(value: T, ms: number): Promise<T> =>
new Promise(resolve => setTimeout(() => resolve(value), ms));
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));

describe("#3766 memo over isPending read beside a second async memo", () => {
for (const variant of ["memo-first", "gate-first", "inline"] as const) {
it(`initial mount: ${variant}`, async () => {
const seen: string[] = [];
createRoot(() => {
const source = createMemo(() => delay(10, 10));
const gate = createMemo(() => delay(100, 20));
const pending = createMemo(() => isPending(source));
createRenderEffect(
() =>
variant === "memo-first"
? `${pending()} | ${gate()}`
: variant === "gate-first"
? `${gate()} | ${pending()}`
: `${isPending(source)} | ${gate()}`,
v => {
seen.push(v);
}
);
});
flush();
await wait(100);
flush();
expect(seen).toEqual([variant === "gate-first" ? "100 | false" : "false | 100"]);
});
}

it("initial mount: a second reader still waiting holds the first", async () => {
const seen: string[] = [];
createRoot(() => {
const source = createMemo(() => delay(10, 10));
const gate = createMemo(() => delay(100, 20));
const late = createMemo(() => delay(200, 40));
const pending = createMemo(() => isPending(source));
createRenderEffect(
() => `a ${pending()} | ${gate()}`,
v => {
seen.push(v);
}
);
createRenderEffect(
() => `b ${pending()} | ${late()}`,
v => {
seen.push(v);
}
);
});
flush();
await wait(100);
flush();
expect(seen).toEqual(["a false | 100", "b false | 200"]);
});

it("initial mount: a plain write while the reader waits on gate", async () => {
const seen: string[] = [];
let setLabel!: (v: string) => void;
createRoot(() => {
const [label, set] = createSignal("a");
setLabel = set;
const source = createMemo(() => delay(10, 10));
const gate = createMemo(() => delay(100, 60));
const pending = createMemo(() => isPending(source));
createRenderEffect(
() => `${label()} ${pending()} | ${gate()}`,
v => {
seen.push(v);
}
);
});
flush();
await wait(30);
setLabel("b");
flush();
expect(seen).toEqual([]);
await wait(100);
flush();
expect(seen).toEqual(["b false | 100"]);
});

it("update: two readers of gate agree", async () => {
let a = "";
let b = "";
let setCount!: (v: number) => void;
createRoot(() => {
const [count, set] = createSignal(1);
setCount = set;
const source = createMemo(() => delay(count(), 10));
const gate = createMemo(() => delay(count() * 100, 20));
const pending = createMemo(() => isPending(source));
const slow = createMemo(() => {
const value = pending();
return delay(value, value ? 30 : 300);
});
createRenderEffect(
() => `${pending()} | ${gate()}`,
v => {
a = v;
}
);
createRenderEffect(
() => `${slow()} | ${gate()}`,
v => {
b = v;
}
);
});
flush();
await wait(400);
flush();
expect([a, b]).toEqual(["false | 100", "false | 100"]);
setCount(2);
flush();
for (let i = 0; i < 50; i++) {
await wait(10);
expect(a.split(" | ")[1]).toBe(b.split(" | ")[1]);
}
expect([a, b]).toEqual(["false | 200", "false | 200"]);
});
});
70 changes: 70 additions & 0 deletions packages/web/test/ispending-memo-gate-3766.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/

import { describe, expect, test } from "vitest";
import { createMemo, createSignal, flush, isPending } from "solid-js";
import { render } from "@solidjs/web";

const delay = <T,>(value: T, ms: number): Promise<T> =>
new Promise(resolve => setTimeout(() => resolve(value), ms));
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));

// Port of https://s.olid.uk/id/lg40u1dpQkC5mIUmTn_Grw, delays divided by 10.
describe("#3766 memo over isPending read beside a second async memo", () => {
test("mounts once both first flights land", async () => {
const div = document.createElement("div");
const dispose = render(() => {
const source = createMemo(() => delay(10, 10));
const gate = createMemo(() => delay(100, 20));
const pending = createMemo(() => isPending(source));
return <p>{`${pending()} | ${gate()}`}</p>;
}, div);
await wait(100);
flush();
expect(div.querySelector("p")?.textContent).toBe("false | 100");
dispose();
});

test("two readers of gate never disagree across an update", async () => {
const div = document.createElement("div");
let setCount!: (v: number) => void;
const dispose = render(() => {
const [count, set] = createSignal(1);
setCount = set;
const source = createMemo(() => delay(count(), 10));
const gate = createMemo(() => delay(count() * 100, 20));
const pending = createMemo(() => isPending(source));
const slow = createMemo(() => {
const value = pending();
return delay(value, value ? 30 : 300);
});
return (
<>
<p id="status">{`${pending()} | ${gate()}`}</p>
<p id="slow">{`${slow()} | ${gate()}`}</p>
</>
);
}, div);
const rows = () => Array.from(div.querySelectorAll("p")).map(p => p.textContent!.split(" | "));
await wait(400);
flush();
expect(rows()).toEqual([
["false", "100"],
["false", "100"]
]);
setCount(2);
flush();
for (let i = 0; i < 50; i++) {
await wait(10);
const [a, b] = rows();
expect(a[1]).toBe(b[1]);
}
expect(rows()).toEqual([
["false", "200"],
["false", "200"]
]);
dispose();
});
});
9 changes: 8 additions & 1 deletion scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,14 @@ module.exports = [
// lane re-stage +13, verdict pull +1). Cap set at measured + 10 B rounded
// up to 0.01 KB. Accepted by the maintainer (2026-10-05). The cap is
// frozen again at 9.49 KB.
limit: "9.49 KB",
// Size-Exception requested (#3766, 2026-10-05): 9.49 -> 9.54 KB, measured
// at 9,521 B on macOS against `next` @ a8c98bd2's 9,486 (+35 B; 31 B over
// the cap; +65 B minified). `laneStage` marks a leaf whose own lane pass
// ends pending so its next pass is the lane's, and hands a held node that
// leaves a lane back to the lane's holder. Lanes are not in the core
// floor (7,349 -> 7,350 B, 0 B minified). Cap set at measured + 10 B
// rounded up to 0.01 KB, pending the maintainer's decision.
limit: "9.54 KB",
alias
},
{
Expand Down
Loading