Summary
Inside an action, a <Show when={latest(x) > 0}> opens at once, which is expected, because latest() is display-ahead. But the new <Loading> it mounts shows content 1 while everything else on screen still shows the committed x = 0. Under the lane rule, a verdict lane's mounts stay mainline, so the mounted content belongs to the screen's world, not the proposal's. This is a torn frame.
Repro (packages/signals/tests, vitest)
import { expect, it } from "vitest";
import {
action,
createLoadingBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
latest,
untrack
} from "../src/index.js";
const settle = async () => {
for (let i = 0; i < 3; i++) {
await new Promise(r => setTimeout(r, 0));
flush();
}
};
it("a mount triggered by latest() shows its content beside the committed value", async () => {
const [x, setX] = createSignal(0);
let screenX: unknown;
let slot: unknown;
createRoot(() => {
// <p>{x()}</p>
createRenderEffect(x, v => {
screenX = v;
});
// <Show when={latest(x) > 0}>
// <Loading fallback="fallback">content {x()}</Loading>
// </Show>
const when = createMemo(() => latest(x) > 0);
const children = createMemo(() =>
when()
? untrack(() =>
createLoadingBoundary(
() => `content ${x()}`,
() => "fallback"
)
)
: "closed"
);
createRenderEffect(
() => {
const c = children();
return typeof c === "function" ? c() : c;
},
v => {
slot = v;
}
);
});
flush();
expect([screenX, slot]).toEqual([0, "closed"]);
let release!: () => void;
const done = action(function* () {
setX(1);
yield new Promise<void>(r => (release = r));
})();
await settle();
// The action holds x = 1; the screen still shows x = 0.
expect(screenX).toBe(0);
// Actual: "content 1" beside the committed 0.
expect(slot).toBe("fallback");
release();
await done;
await settle();
expect([screenX, slot]).toEqual([1, "content 1"]);
});
The repro fails at expect(slot).toBe("fallback") with Received: "content 1". The same thing happens when the content is a memo, createMemo(() => \content ${x()}`)`.
Expected vs actual
| Checkpoint |
Expected |
Actual |
| Before the action |
x = 0, slot closed |
x = 0, slot closed |
Action holds x = 1 |
x = 0, slot fallback |
x = 0, slot content 1 |
| Action ends |
x = 1, slot content 1 |
x = 1, slot content 1 |
Rules involved
- The lane rule: an optimistic lane sees the screen plus its own guesses, and its mounts' children are the lane's. A verdict lane computes the real outcome, and its mounts stay mainline. Here the
Show condition is verdict-lane work, and its children are seated in that lane. The new Loading's first pass inherits the lane from the pass that created it, so it reads the proposal x = 1.
- A29's boundary scope (2026-10-06): a loading boundary that has not shown content owns its subtree. Content that reads a hold waits behind the boundary's fallback, and it appears at the hold's commit.
- No tearing: content on screen agrees with the screen's value of what it reads.
The suspected site is the first-pass lane rule in recompute (core/core.ts). A first pass takes its creator's lane, which is right for an optimistic lane (ruling A). For a verdict lane, it should stay mainline.
Where it reproduces
Provenance
Found by the semantic fuzzer's mount-under-hold cohort, verdict family (fuzz/semantic-fuzzer-l2, b2bdf125a, rules MH1 and MH5). It probably shares a root with the read-order mount family (fuzzer cases 827, 416 and 936). There, a latest() read routes a freshly mounted child into the verdict lane, its mount control publishes alone, and the slot stays empty. In both cases a pass picks up verdict-lane membership that the lane rule says stays mainline: through a read in the read-order family, and through its creator here.
Summary
Inside an action, a
<Show when={latest(x) > 0}>opens at once, which is expected, becauselatest()is display-ahead. But the new<Loading>it mounts showscontent 1while everything else on screen still shows the committedx = 0. Under the lane rule, a verdict lane's mounts stay mainline, so the mounted content belongs to the screen's world, not the proposal's. This is a torn frame.Repro (
packages/signals/tests, vitest)The repro fails at
expect(slot).toBe("fallback")withReceived: "content 1". The same thing happens when the content is a memo,createMemo(() => \content ${x()}`)`.Expected vs actual
x= 0, slotclosedx= 0, slotclosedx = 1x= 0, slotfallbackx= 0, slotcontent 1x= 1, slotcontent 1x= 1, slotcontent 1Rules involved
Showcondition is verdict-lane work, and its children are seated in that lane. The newLoading's first pass inherits the lane from the pass that created it, so it reads the proposalx = 1.The suspected site is the first-pass lane rule in
recompute(core/core.ts). A first pass takes its creator's lane, which is right for an optimistic lane (ruling A). For a verdict lane, it should stay mainline.Where it reproduces
next(ba67ddbaa)ae59c7682)3ab71b0dc)proto/3540-boundary-scope(60d2f1ded). Its SPEC draft already lists this shape as open ("a boundary a verdict reader mounts … shows the held derivation now rather than its fallback").Provenance
Found by the semantic fuzzer's mount-under-hold cohort,
verdictfamily (fuzz/semantic-fuzzer-l2,b2bdf125a, rules MH1 and MH5). It probably shares a root with the read-order mount family (fuzzer cases 827, 416 and 936). There, alatest()read routes a freshly mounted child into the verdict lane, its mount control publishes alone, and the slot stays empty. In both cases a pass picks up verdict-lane membership that the lane rule says stays mainline: through a read in the read-order family, and through its creator here.