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

A render effect still on screen while an action holds its removal follows the lane's re-derivation of a memo it reads, as its direct `latest()` twin does (fuzzer adopted-staging case)
5 changes: 5 additions & 0 deletions .changeset/lane-stale-reader-unheld-lane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A mainline mount whose child reads a lane derivation in flight shows the child as a stale reader beside its mount control, whether or not the lane is held (fuzzer F8)
96 changes: 48 additions & 48 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t

**Membership in a hold is the tick's — the synchronous work one flush settles (2026-10-04, maintainer ruling; ruling 1 clarified, surfaced by #3761's review).** The frame is the tick, and a frame that derives from two futures waits on both: two mainline passes created before the same flush — no `flush()` between them — that each read a _different_ foreign hold join both, and the holds merge (`passTx` is one per tick, cleared by the flush that consumes it — `joinPassTx` merges a second transaction into it — exactly as `joinFuture` merges inside a flush). A manual `flush()` between two passes puts them in different frames: the second pass is a new tick, escapes the first's transaction, and the two holds stay independent. The tick is defined by the flush, not by a microtask, `await` or timer boundary. A29's "the entry is the pass's alone" scopes the entry away from the tick's other _writes_ (a write made after the mount is a mainline write, a render effect mounted beside it a stale reader), not from the tick's other passes. A15's "writes on fully disjoint graphs keep independent transitions" is about passes in different ticks. Pinned: `tests/tick-scoped-pass-transaction.test.ts` (`mountA(); mountB(); flush()` → the holds merge, both reveal at the second release, `isPending` true on both until then; `mountA(); flush(); mountB(); flush()` → independent, A reveals at its own release while B stays held).

**A lane's readers are placed by the screen, not by the lane's state (2026-10-05, maintainer ruling — reading A for all three; the semantic fuzzer's F8, F6 and adopted-staging cases).** A render effect off a lane that reads a lane node directly is a stale reader of the lane whether or not the lane is held at that moment, and a leaf reading the lane through a boundary that shows content is the lane's frame reader through it — and a leaf whose removal an adopted write stages (O1) still follows the lane's work while it is on screen (reading A, ruled), rather than waiting with the tick that staged its removal (reading B, rejected). **F8:** a mainline mount over a lane derivation whose re-ask nobody had observed (the lane not held) publishes its control, and a child reading the derivation directly shows the screen — the lane's revealed derivation — and re-derives at the reveal (A15 lanes corollary, #3460). Before, the child entered the lane and the control published without it, so control and content disagreed. Pre-L2 held the whole mount, which entangles more than the rule names. A derivation between the lane node and the leaf (a memo, a boundary's tree memo) is not a leaf: the mount is born held under A29's creation-time form and reveals control and content together at the landing, as #3761 rules for a mount over a foreign hold. Derivations carry the future, and that variant stays held. **F6:** a `Loading` showing content is transparent (A33, B5 — "the root holds the frame as for any reader"), so the leaf reading its output holds the lane on the derivation under it, as the same leaf reading the derivation directly does (A17; `createMemo.test.ts` A10 lane pins). The lane does not reveal `latest = 1` beside the boundary's `0`, and both show at the landing. A boundary on its fallback still holds nothing (A33). An `on`-reset boundary's one-checkpoint `[1, 0]` is a separate, older question and is not decided here. **Adopted staging:** a render effect under a mount whose withdrawal the action adopted (O1) reads a memo the lane recomputes. Its removal is staged, so it is still on screen (a zombie, live for every hold but the staging one's, #3463). Ruled (reading A): it follows the lane's work and shows `[1]`, as the same effect reading `latest()` directly already does (#3444), because wrapping a `latest` read in a memo does not change what it answers (A28 (1)). Rejected (reading B): the tick is one frame and the adopted hide makes the subtree the action's work, so it would keep the committed `[0]` until the hide commits or nets out while `latest()` read directly peers through by #3444's exception — and a sibling reader of the same memo off the withdrawn subtree shows `[1]` beside it, two on-screen readers of one memo disagreeing. Pre-L2 showed `[1]`. Pinned: `tests/fuzz-findings-l2.test.ts` "F8: …", "F8 (memo between): …", "adopted staging: …". F6 is ruled but not yet implemented: the boundary's output counting as the lane's frame reader also holds a lane the transaction dissolves before it is shown, which A18 (c)'s pins (`spec-async-semantics.test.ts` "differing arrival…", "sync wrapper…"; `store/lane-authority-twins.test.ts` #3331) expect to reveal — its pin stays `it.fails` until A18 (c) is read for that case.

## Reads and visibility — what a read serves

### A17. An active override is the displayed value until its transaction commits, and the graph's value until its own source answers
Expand Down
7 changes: 4 additions & 3 deletions packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,9 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal<
* — and is no lane's; an untracked read sees the lane's latest value (A17:
* "direct read shows optimistic"; A28); a children-forbidden reader sees
* the frame (A32). A render effect in the frame's seat reading a lane the
* seam found blocked and that has not shown sees the screen — the committed
* value, re-derived at the reveal (#3460's stale reader) — or, the node a
* seam found blocked, or a member whose own flight is up (held or not — the
* lane-membership ruling, 2026-10-05), sees the screen, re-derived at the
* reveal (#3460's stale reader) — or, the node a
* flight that has never shown (nothing to show), waits on it: its own frame
* holds (A15, #3334), not the lane's parent. Any other tracked reader is
* the lane's work from this read — a derivation; a leaf reading a lane that
Expand Down Expand Up @@ -531,7 +532,7 @@ export function laneRead(c: Computed<any> | null, el: Signal<any> | Computed<any
)
return el._pendingValue;
}
if (l._held) {
if (l._held || (!guess && status & STATUS_PENDING)) {
if (!guess && !l._shown && status & STATUS_UNINITIALIZED) throw new NotReadyError(null);
// Re-derived at the reveal; once per pass (the pass may also be a stale
// reader of a transaction, REACTIVE_FRAME_READ — both reruns apply).
Expand Down
8 changes: 6 additions & 2 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,11 +1084,15 @@ export function insertSubs(node: Signal<any> | Computed<any>): void {
/** Lane work re-staged (lanes.ts; `recompute`): the lane's members among
* its subscribers re-derive as the lane's work (REACTIVE_LANE_DIRTY), their
* runs held with it — not as stale readers republishing the committed view
* (#3460). After `insertSubs`. */
* (#3460). After `insertSubs`. A render effect on no lane is marked too:
* inert for its pass (`recompute` seats a lane only from its own `_x`), it
* is a live write if the effect becomes a zombie later this flush — an
* on-screen reader whose removal an adopted write stages follows the lane
* (the lane-membership ruling, 2026-10-05; `_update`). */
export function laneDirty(node: Signal<any> | Computed<any>, l: Transaction): void {
for (let s = node._subs; s !== null; s = s._nextSub) {
const t = s._sub._x?._transaction;
if (t != null && sameLane(t, l)) s._sub._flags |= REACTIVE_LANE_DIRTY;
if (t?._lane ? sameLane(t, l) : (s._sub as any)._type) s._sub._flags |= REACTIVE_LANE_DIRTY;
}
}
/** One lane, or two of one link group (#3335): one reveal unit. */
Expand Down
281 changes: 213 additions & 68 deletions packages/signals/tests/fuzz-findings-l2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,79 +445,224 @@ describe("fuzz findings on L2 — lanes", () => {

// F8. A15 lanes corollary (#3460): "a render effect OFF the lane that reads
// what the lane is revealing … shows the committed value, publishes now,
// entangles nothing — and re-derives at the release"; ruling 1: a flush
// parks as a whole. A mainline mount whose child reads a lane-held pending
// derivation publishes the mount control (`mounted=true`) while the child
// never attaches until the action ends — neither the committed value now
// nor a held frame: a torn mount (fuzzer S2 "mount control").
it.fails(
"F8: a mainline mount reading a lane-held pending derivation shows with its mount control (A15 lanes corollary)",
async () => {
const [source, setSource] = createSignal(0);
const [mounted, setMounted] = createSignal(false);
const gates = new Map<string, () => void>();
let resume!: () => void;
let run!: () => Promise<void>;
let shownMounted: unknown;
let child: unknown = "absent";
let dispose!: () => void;
createRoot(d => {
dispose = d;
const derived = createMemo(() => {
const v = latest(source);
return gated(gates, `d:${v}`, v);
// entangles nothing — and re-derives at the release"; the lane-membership
// ruling (2026-10-05, reading A): a stale reader of the lane "whether or
// not the lane is held at that moment". A mainline mount whose child reads
// a lane derivation whose re-ask nobody had observed (the seam left the
// lane unheld) published the mount control while the child entered the
// lane and waited on the flight — a torn mount (fuzzer S2 "mount
// control"). `laneRead`'s stale-reader arm was gated on `_held`; it now
// also takes a leaf reading a lane member whose own flight is up: the
// child shows the screen (the lane's revealed derivation) beside its
// control, and re-derives at the reveal. Through a memo the mount is born
// held instead (A29, #3761): the next pin.
it("F8: a mainline mount over a lane derivation in flight shows its child as a stale reader beside its mount control (A15 lanes corollary)", async () => {
const [source, setSource] = createSignal(0);
const [mounted, setMounted] = createSignal(false);
const gates = new Map<string, () => void>();
let resume!: () => void;
let run!: () => Promise<void>;
let shownMounted: unknown;
let child: unknown = "absent";
let dispose!: () => void;
createRoot(d => {
dispose = d;
const derived = createMemo(() => {
const v = latest(source);
return gated(gates, `d:${v}`, v);
});
createRenderEffect(mounted, v => {
shownMounted = v;
});
createRenderEffect(
() =>
mounted()
? createRoot(dd => {
createRenderEffect(derived, v => {
child = v;
});
onCleanup(() => {
child = "absent";
});
return dd;
})
: undefined,
dd => {
if (dd) onCleanup(dd);
}
);
run = action(function* () {
setSource(1);
yield new Promise<void>(r => {
resume = r;
});
createRenderEffect(mounted, v => {
shownMounted = v;
setSource(2);
yield new Promise<void>(r => {
resume = r;
});
createRenderEffect(
() =>
mounted()
? createRoot(dd => {
createRenderEffect(derived, v => {
});
});
flush();
gates.get("d:0")!();
await drain();
const p = run();
await drain();
gates.get("d:1")!(); // the lane's first derivation lands
await drain();
resume(); // source=2: the derivation re-asks, the lane is held again
await drain();
setMounted(true); // a mainline mount over the held derivation
await drain();
// The control publishes; the child shows the lane's screen beside it.
expect([shownMounted, child]).toEqual([true, 1]);
gates.get("d:2")!();
await drain();
expect([shownMounted, child]).toEqual([true, 2]); // re-derived at the reveal
resume();
await p;
await drain();
dispose();
});

it("F8 (memo between): a mainline mount deriving from a lane derivation in flight is born held, control and child together (A29, #3761)", async () => {
const [source, setSource] = createSignal(0);
const [mounted, setMounted] = createSignal(false);
const gates = new Map<string, () => void>();
let resume!: () => void;
let run!: () => Promise<void>;
let shownMounted: unknown;
let child: unknown = "absent";
let dispose!: () => void;
createRoot(d => {
dispose = d;
const derived = createMemo(() => {
const v = latest(source);
return gated(gates, `d:${v}`, v);
});
createRenderEffect(mounted, v => {
shownMounted = v;
});
createRenderEffect(
() =>
mounted()
? createRoot(dd => {
createRenderEffect(
createMemo(() => derived()),
v => {
child = v;
});
onCleanup(() => {
child = "absent";
});
return dd;
})
: undefined,
dd => {
if (dd) onCleanup(dd);
}
);
run = action(function* () {
setSource(1);
yield new Promise<void>(r => {
resume = r;
});
setSource(2);
yield new Promise<void>(r => {
resume = r;
});
}
);
onCleanup(() => {
child = "absent";
});
return dd;
})
: undefined,
dd => {
if (dd) onCleanup(dd);
}
);
run = action(function* () {
setSource(1);
yield new Promise<void>(r => {
resume = r;
});
setSource(2);
yield new Promise<void>(r => {
resume = r;
});
});
flush();
gates.get("d:0")!();
await drain();
const p = run();
await drain();
gates.get("d:1")!(); // the lane's first derivation lands
await drain();
resume(); // source=2: the derivation re-asks, the lane is held again
await drain();
setMounted(true); // a mainline mount over the held derivation
await drain();
// The mount control and its child are one frame: both show, or neither.
expect(shownMounted === true).toBe(child !== "absent");
gates.get("d:2")!();
resume();
await p;
await drain();
dispose();
}
);
});
flush();
gates.get("d:0")!();
await drain();
const p = run();
await drain();
gates.get("d:1")!(); // the lane's first derivation lands
await drain();
resume(); // source=2: the derivation re-asks, the lane is held again
await drain();
setMounted(true); // a mainline mount over the held derivation
await drain();
// A derivation carries the future: the whole mount waits for it.
expect([shownMounted, child]).toEqual([false, "absent"]);
gates.get("d:2")!();
await drain();
expect([shownMounted, child]).toEqual([true, 2]); // revealed together
resume();
await p;
await drain();
dispose();
});

// Adopted staging (ex-"R-oracle", latest cohort). The lane-membership
// ruling (2026-10-05, reading A): an on-screen render effect whose removal
// an adopted write has staged follows the lane's re-derivation of a memo
// it reads; A15 #3463: a zombie is live for every hold until the commit
// that disposes it; A28 (1): wrapping a `latest` read in a memo does not
// change what it answers. An action writes `source = 1`; the same tick
// withdraws the mount around `E` (adopted, O1), so `E` is a zombie of the
// action's frame. The lane recomputes `m = [latest(source)]`; `E`'s pass
// was deferred as a zombie's and the park cancelled it — `E` kept `[0]`
// while a sibling reader of `m` showed `[1]` (the direct `latest()` twin
// already re-ran, #3444). The lane's re-staging of `m` marks `E` too
// (`laneDirty`: a render effect on no lane), so its zombie pass is a live
// write and runs now, as the lane's dirty members do.
it("adopted staging: a reader whose removal an action adopted follows the lane's re-derivation of a memo it reads (A15 #3463, A28 (1))", async () => {
const [source, setSource] = createSignal(0);
const [mounted, setMounted] = createSignal(true);
let resume!: () => void;
let run!: () => Promise<void>;
let E: unknown = "absent",
S: unknown,
M: unknown;
let dispose!: () => void;
createRoot(d => {
dispose = d;
const m = createMemo(() => [latest(source)]);
createRenderEffect(m, v => {
S = JSON.stringify(v);
});
createRenderEffect(mounted, v => {
M = v;
});
createRenderEffect(
() =>
mounted()
? createRoot(dd => {
createRenderEffect(m, v => {
E = JSON.stringify(v);
});
onCleanup(() => {
E = "absent";
});
return dd;
})
: undefined,
dd => {
if (dd) onCleanup(dd);
}
);
run = action(function* () {
setSource(1);
yield new Promise<void>(r => {
resume = r;
});
});
});
flush();
expect([M, E, S]).toEqual([true, "[0]", "[0]"]);
const p = run();
setMounted(false); // the action's tick: the withdrawal rides with it (O1)
await drain();
// The withdrawal is held: E is still on screen, and shows the lane's frame.
expect([M, E, S]).toEqual([true, "[1]", "[1]"]);
resume();
await p;
await drain();
expect([M, E, S]).toEqual([false, "absent", "[1]"]);
dispose();
});

// F13. A29 (creation-time form): a reader born held is "staged into [the
// transaction], committed with it" — `recompute`'s own note: "An effect
Expand Down
18 changes: 16 additions & 2 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -2056,7 +2056,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 28.84 KB.
limit: "28.84 KB",
// Size-Exception (fuzz Batch C, #3806, 2026-10-05): 28.84 -> 28.87 KB,
// measured at 28,857 B by CI against `next` @ b07fed550's 28,829
// (+28 B; 17 B over the cap; +13 B minified) — the lane-membership
// ruling: F8's stale-reader arm in laneRead (+9) and adopted staging's
// render-effect mark in laneDirty (+4). 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 28.87 KB.
limit: "28.87 KB",
alias
},
{
Expand Down Expand Up @@ -2934,7 +2941,14 @@ module.exports = [
// is brotli layout (the same change measured -10 B against 203ab1a43).
// Engine-only: every prod scenario byte-identical, the observe tier
// scenario above did not move.
limit: "28.62 KB",
// Size-Exception (fuzz Batch C, #3806, 2026-10-05): 28.62 -> 28.66 KB,
// measured at 28,642 B by CI against `next` @ b07fed550's 28,611
// (+31 B; 22 B over the cap; +4 B minified) — the lane-membership
// ruling (F8's laneRead arm, adopted staging's laneDirty mark), signals
// core only; brotli layout over the engine's bundle. 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 28.66 KB.
limit: "28.66 KB",
alias: observeAlias
},
{
Expand Down
Loading