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/held-frame-verdict-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A render effect in a branch an action holds (`<Show when={y() > 0}>` opened by the action's write) no longer runs ahead of that branch's landing when it reads `latest(y)` inline, while the same read through a memo waited: a member of the frame a held pass built is the hold's own work, so neither form seats it as the hold's verdict reader, and memo wrapping no longer changes what it shows.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:111` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… |
| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:303` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… |
| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:95` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 seat.ts×3 types.ts×1 verdict.ts×2 store.ts×5 | continuation-sibling-reader-4146.test.ts×1 createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 issue-3800-repro.test.ts×1 latest-held-till-flush.test.ts×1 latest-memo-inline-parity.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… |
| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:119` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×14 effect.ts×1 lanes.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fallback-born-creator-4428.test.ts×1 fuzz-findings-l2.test.ts×6 held-branch-verdict-reader-4010.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 verdict-born-into-other-flush-4426.test.ts×1 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … |
| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:119` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×14 effect.ts×1 lanes.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fallback-born-creator-4428.test.ts×1 fuzz-findings-l2.test.ts×6 held-branch-verdict-reader-4010.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-frame-verdict-parity.test.ts×1 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 verdict-born-into-other-flush-4426.test.ts×1 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … |
| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:259` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 projection.ts×1 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 projection-peek-loop-4342.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… |
| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:133` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 probe-pulled-pass-4429.test.ts×1 unowned-memo-ispending-reader-4366.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … |
| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | core.ts×4 lanes.ts×1 seat.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… |
Expand Down
24 changes: 14 additions & 10 deletions packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,17 @@ export function enterLane(l: Transaction, c: Computed<any>): void {
// does not break it out — it stays the frame's, as the same read inline
// does (verdict.ts `markVerdictReader`, `route`; D-25, D-26). An
// effect's own reads do not count (its `_type`, 1–3, carries neither bit).
if (!(verdict && creatorReads(c, (c as any)._type || f))) {
// Nor by a member of the frame a held pass of that lane's hold built,
// on any later run (CONFIG_HELD_FRAME, `bornInto`): born in the hold's
// world, it is the hold's own work and its run is the frame's, at the
// landing (D-25, D-27) — as `route` keeps it for the inline `latest()`.
if (
!(
verdict &&
(creatorReads(c, (c as any)._type || f) ||
(c._config & CONFIG_HELD_FRAME && bornInto(c, resolveTx(l._parent!))))
)
) {
setPassLane(l);
// The pass read a held write before its first lane read (it joined the
// frame's future as mainline would): lane work sees the screen — the
Expand Down Expand Up @@ -713,17 +723,11 @@ export function laneRead(c: Computed<any> | null, el: Signal<any> | Computed<any
// a flight — the pass leaves the lane, and its frame holds as if the
// read had come first (S-39, #4275). Nor does a verdict lane's seat carry
// the leaf into another lane (no link): it reads that lane as from the
// frame's seat (#4284). The seat is the hold's, not the lane object's: a
// transaction merged into the hold keeps its own verdict lane, and that
// lane is the same hold's (D-4 — merged holds are one; #4427).
// frame's seat (#4284).
const p = passLane;
const seated = p?._parent!._verdict === p;
if (
seated &&
(never || l._parent!._verdict !== l || resolveTx(l._parent!) !== resolveTx(p!._parent!))
)
setPassLane(null);
if (passLane === null || (!seated && !sameLane(passLane, l))) {
if (seated && (never || p !== l)) setPassLane(null);
if (passLane === null || !sameLane(passLane, l)) {
// (A lane that has never shown: the screen is the committed value.)
if (never && l._shown) return NOT_PENDING;
if (
Expand Down
45 changes: 19 additions & 26 deletions packages/signals/src/core/seat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,17 @@ function applyRaise(seat: Seat, p: Prediction): void {
if (seat.ownWorld > seat.world) seat.world = seat.ownWorld;
// Display-ahead (today `route`): the pass is the hold's verdict reader
// unless already seated, or already the frame's (it read the future or a
// staging: world > S).
if (p.raise & MARK_AH && p.tx !== null && !seated(seat) && seat.ownWorld === WORLD_S)
// staging: world > S), or a member of the frame a held pass of that hold
// built (the held-frame clause; D-25: born in the hold's world, it reads
// plainly — a probe does not rescue it; D-27: its runs are the frame's,
// which commits with the hold).
if (
p.raise & MARK_AH &&
p.tx !== null &&
!seated(seat) &&
seat.ownWorld === WORLD_S &&
!(seatBornInto(seat, p.tx) && seat.node!._config & CONFIG_HELD_FRAME)
)
seat.vhold = resolveTx(p.tx);
if (p.raise & RAISE_VSEAT && p.tx !== null) {
// (A lane the pass sat in links to the verdict lane, `enterLane`.)
Expand Down Expand Up @@ -718,9 +727,15 @@ export function slot(
// verdict lane's — the pass is the hold's verdict reader, unless its
// branch exists only in the future (`creatorReads`: a pass whose
// running creators, or itself when a memo, read the future or a
// staging — D-25, D-26, #4341).
// staging — D-25, D-26, #4341), or a member of the frame a held pass
// of the lane's hold built (the held-frame clause, D-25 D-27).
const enter = vlane
? inSeat || !(seat!.creatorsWorld > WORLD_S || (!isEffect && seat!.ownWorld > WORLD_S))
? inSeat ||
!(
seat!.creatorsWorld > WORLD_S ||
(!isEffect && seat!.ownWorld > WORLD_S) ||
(seatBornInto(seat, l._parent!) && c!._config & CONFIG_HELD_FRAME)
)
? RAISE_VSEAT
: 0
: RAISE_LANE;
Expand Down Expand Up @@ -1272,28 +1287,6 @@ const known: KnownDifference[] = [
flushTransaction !== null &&
seatBornInto(seat, txOf(el!)) &&
resolveTx(flushTransaction) !== resolveTx(txOf(el!))
},
{
// laneRead's D-28 reset compares verdict lanes by identity: a lane whose
// parent merged into the seat's hold reads as another hold's.
issue: 4427,
kind: "slot",
row: "V6",
expected: SLOT_LV,
actual: SLOT_P,
when: (c, el, seat) => {
const l = el!._x?._transaction;
return (
(c as any)._type === EFFECT_RENDER &&
seat.vhold !== null &&
!!l &&
l._lane &&
laneOf(l) === null &&
l._parent !== null &&
resolveTx(l._parent) !== l._parent &&
resolveTx(l._parent) === resolveTx(seat.vhold)
);
}
}
];
const knownHits = new Map<number, number>();
Expand Down
14 changes: 12 additions & 2 deletions packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
CONFIG_GUESS,
CONFIG_ASYNC,
CONFIG_HELD,
CONFIG_HELD_FRAME,
CONFIG_INPUTS_PUBLISHED,
CONFIG_OVERRIDE,
CONFIG_STAGED_ERROR,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
joinFuture,
passLane,
queuePendingNode,
resolveTx,
setPassLane,
stagedReaders,
staleReader,
Expand Down Expand Up @@ -94,10 +96,18 @@ function watchVerdict(c: Computed<any>, unanswered = false): void {
/** The reading pass is the holder's verdict lane's — unless already lane
* work, or already the transaction's (it derives from the future — a held
* node's, or the staging of a write this flush may hold — and the verdict
* does not rescue it: its result shows at the landing, re-derived). A lane
* does not rescue it: its result shows at the landing, re-derived), or a
* member of the frame a held pass of that hold built (CONFIG_HELD_FRAME,
* `bornInto`): the hold's own work, born in its world, reads plainly and
* its run is the frame's (D-25, D-27) — the same leaf reading a memo of the
* `latest()` waits for the landing too (`enterLane`; E2, #4284). A lane
* read, for `recompute`'s tail (REACTIVE_LANE_READ). */
function route(c: Computed<any>, t: Transaction): void {
if (!passLane && !(c._flags & (REACTIVE_JOINED | REACTIVE_STAGED_READ)))
if (
!passLane &&
!(c._flags & (REACTIVE_JOINED | REACTIVE_STAGED_READ)) &&
!(c._config & CONFIG_HELD_FRAME && bornInto(c, resolveTx(t)))
)
setPassLane(verdictLane(t));
c._flags |= REACTIVE_LANE_READ;
}
Expand Down
181 changes: 181 additions & 0 deletions packages/signals/tests/held-frame-verdict-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* E2 / #4284 (memo wrapping and grouping never change the result) for a
* leaf in a held frame. `<Show when={y() > 0}>` opened by an action holding
* `y`: the branch is that hold's frame (CONFIG_HELD_FRAME — a held pass
* built it), never on screen until the landing. A leaf in it reading the
* hold's display-ahead value — `latest(y)` inline, or a memo of it (a
* verdict-lane node) — re-runs on the hold's next step.
*
* Before, the inline leaf's `latest(y)` seated it as the hold's verdict
* reader (`route`), so its run went out with the verdict lane's reveal,
* while the same read through a memo was served the memo's staging as the
* hold's own work (`laneRead`'s L7' arm) and its run waited for the
* landing: `[10, 20]` beside `[10]`. A member of the frame a held pass of
* the hold built is the hold's own work, born in its world: it reads
* plainly and a probe does not rescue it (D-25, as #4426 for its reads);
* its runs are the frame's, which commits with the hold (D-27's joined
* pass, A29 ruling A). Every form now waits for the landing.
*
* The two-hold shape (the #4455 review): A holds `x`, `m = memo(latest(x)
* * 10)` is A's verdict lane's, B holds `y`, and `k` reads both — A merges
* into B. The leaf `[latest(y), m()]` inline, through a memo, or split into
* two leaves shows the same at every step.
*/
import { describe, expect, it } from "vitest";
import {
action,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
latest
} from "../src/index.js";

const tick = async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
flush();
};

describe("a held frame's leaf over the hold's display-ahead value", () => {
async function single(form: "inline" | "memo inside" | "memo outside") {
const [y, setY] = createSignal(0);
let b1!: () => void, b2!: () => void;
const actB = action(function* () {
setY(1);
yield new Promise<void>(r => (b1 = r));
setY(2);
yield new Promise<void>(r => (b2 = r));
});
const leaf: unknown[] = [];
let shown: unknown;
createRoot(() => {
const outside = createMemo(() => latest(y) * 10);
const when = createMemo(() => y() > 0);
const content = createMemo(() => {
if (!when()) return "off";
const read =
form === "inline"
? () => latest(y) * 10
: form === "memo inside"
? createMemo(() => latest(y) * 10)
: outside;
createRenderEffect(read, v => void leaf.push(v));
return "on";
});
createRenderEffect(content, v => {
shown = v;
});
});
flush();
const steps: unknown[] = [];
actB();
await tick();
steps.push([shown, ...leaf]);
b1();
await tick();
await tick();
steps.push([shown, ...leaf]);
b2();
await tick();
await tick();
steps.push([shown, ...leaf]);
return steps;
}

const expected = [
["off", 10],
["off", 10],
["on", 10, 20]
];
for (const form of ["inline", "memo inside", "memo outside"] as const)
it(`one hold, ${form}: the leaf's run waits for the landing`, async () => {
expect(await single(form)).toEqual(expected);
});

async function merged(form: "inline" | "memo" | "split") {
const [x, setX] = createSignal(0);
const [y, setY] = createSignal(0);
let a1!: () => void, a2!: () => void, b1!: () => void;
const actA = action(function* () {
setX(1);
yield new Promise<void>(r => (a1 = r));
setX(2);
yield new Promise<void>(r => (a2 = r));
});
const actB = action(function* () {
setY(1);
yield new Promise<void>(r => (b1 = r));
});
let shownM: unknown, shown: unknown, leaf: unknown;
createRoot(() => {
const m = createMemo(() => latest(x) * 10);
const k = createMemo(() => x() + y());
createRenderEffect(m, v => {
shownM = v;
});
createRenderEffect(k, () => {});
const when = createMemo(() => y() > 0);
const content = createMemo(() => {
if (!when()) return "off";
if (form === "inline")
createRenderEffect(
() => `${latest(y)}/${m()}`,
v => {
leaf = v;
}
);
else if (form === "memo") {
const mm = createMemo(() => `${latest(y)}/${m()}`);
createRenderEffect(mm, v => {
leaf = v;
});
} else {
let ly: unknown, lm: unknown;
createRenderEffect(
() => latest(y),
v => {
ly = v;
leaf = `${ly}/${lm}`;
}
);
createRenderEffect(m, v => {
lm = v;
leaf = `${ly}/${lm}`;
});
}
return "on";
});
createRenderEffect(content, v => {
shown = v;
});
});
flush();
const steps: unknown[] = [];
actA();
await tick();
actB();
await tick();
steps.push([shownM, shown, leaf]);
a1();
await tick();
await tick();
steps.push([shownM, shown, leaf]);
a2();
b1();
await tick();
await tick();
steps.push([shownM, shown, leaf]);
return steps;
}

const expectedMerged = [
[10, "off", "1/10"],
[20, "off", "1/10"],
[20, "on", "1/20"]
];
for (const form of ["inline", "memo", "split"] as const)
it(`merged holds, ${form}: the same screens`, async () => {
expect(await merged(form)).toEqual(expectedMerged);
});
});
Loading
Loading