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/optimistic-second-guess-reask-4629.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A second action's guess into an optimistic slot now shows at once while the first action's `refresh()` of a list the overlay also reads is still in flight, whatever order the overlay reads the two in, and whether it reads the guess directly or through a memo derived from it (#4629).
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ What is rendered from a display-ahead read reveals together with it: an async me

A held display-ahead world ("a lane") is a hold seen from the outside. A render effect _off_ it that reads what it is revealing — mounted mid-hold, or re-run by an unrelated sync write — shows the committed value (what is on screen), publishes now, entangles nothing, and re-derives at the release: `Late: 0` at the mount, `Details: 1 | Late: 1 | Value: 1` at the release. `latest()` is not special here: a `createOptimistic` source behaves the same. A reader whose removal is staged in a live hold is still on screen and keeps holding the lane while visible; `latest()` read in such a branch peers through to the proposal. A direct leaf reading a lane's derivation shows the lane's revealed value and re-derives at the reveal (control and content never disagree); a derivation between (a memo, a boundary's tree) carries the future and is born held with its mount (D-2). The same holds for a render effect that reads an optimistic value before its lane is judged: if the lane holds on a derivation's flight, the effect shows the committed value and `latest()` of the guess shows the guess beside it, whether the two reads share one render effect or not — `1 x1 / 5`, then `5 x5 / 5` at the landing (#4284). **Inside the lane, "sees the parent's landings" is about computing, not showing (clarified 2026-10-28):** a lane derivation that reads a value the parent holds joins that hold (D-1) — it computes now, so async downstream of it starts without waiting for the commit, and its result shows at the commit as the hold's work. What lane work _displays_ is the screen plus the lane's own values: its guesses, or what its `latest()` reads asked for. A plain read in lane work of a node the parent holds and the lane does not show ahead is the screen's value, in a verdict lane as in an optimistic one (F-6: "a plain read beside it is a read"); `[s(), L()]` with `L = createMemo(() => latest(s))` shows `v:0 l:1`, as the inline `[s(), latest(s)]` does. A15 #3460's sentence is the computing half.

**Collapses:** [A15](./SPEC-ASYNC-SEMANTICS.md#a15-transition-entanglement-is-graph-driven-lanes-settle-as-one-reveal) lanes corollary (#3460, #3463, #3479, #3662, #3698), #3444, the fuzzer-finding F8 ruling (2026-10-05, #3806), the adopted-staging ruling. **Status:** standing; fuzzer finding F5b ("a stale reader of a held re-guess shows the revealed guess") pinned `it.fails`. Grouping: `tests/lane-render-effect-grouping-4284.test.ts`. Known deviation (#4629, 2026-11-02): a second action's guess into a slot whose earlier action's `refresh()` is in flight waits for that re-ask to land instead of showing now beside the screen's value of the re-asked node (the plain read in lane work above); pinned `it.fails` in `tests/projection-second-guess-4629.test.ts`.
**Collapses:** [A15](./SPEC-ASYNC-SEMANTICS.md#a15-transition-entanglement-is-graph-driven-lanes-settle-as-one-reveal) lanes corollary (#3460, #3463, #3479, #3662, #3698), #3444, the fuzzer-finding F8 ruling (2026-10-05, #3806), the adopted-staging ruling. **Status:** standing; fuzzer finding F5b ("a stale reader of a held re-guess shows the revealed guess") pinned `it.fails`. Grouping: `tests/lane-render-effect-grouping-4284.test.ts`. Fixed 2026-11-02 (#4629, #4633, #4637): a second action's guess into a slot whose earlier action's `refresh()` is in flight shows now, beside the screen's value of the re-asked node (the plain read in lane work above), whatever order the pass reads them in (D-28); `tests/projection-second-guess-4629.test.ts`, `tests/optimistic-second-guess-reask-4629.test.ts`.

### D-27. A verdict reader's mount is a mainline mount

Expand Down
21 changes: 19 additions & 2 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1426,8 +1426,25 @@ export function insertSubs(node: Signal<any> | Computed<any>): void {
* `laneRead` re-derives it on the screen if that lane holds (`entered`). */
export function laneDirty(node: Signal<any> | Computed<any>, l: Transaction): void {
for (let s = node._subs; s !== null; s = s._nextSub) {
const c = s._sub,
t = c._x?._transaction;
const c = s._sub;
let t = c._x?._transaction;
// A reader the parent holds pending on its re-ask (a correction's
// re-derivation waiting on a refresh) re-derives from the guess as the
// lane's work, seated there before its pass: lane work reads the
// parent's flight as the screen (D-26, D-37), whether it reads the
// flight before the guess or after (D-28), and whether it reads the
// guess itself or a derivation of it — this walk runs at every lane
// node's change. Seated at the guess read, the earlier read threw the
// pass back into the hold (#4629).
if (
t &&
l._parent &&
!(c as any)._type &&
(c._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD &&
(c._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) === STATUS_PENDING &&
resolveTx(t) === resolveTx(l._parent)
)
list(c, (t = l));
if ((c as any)._type === EFFECT_RENDER && node._config & CONFIG_VERDICT)
((c._config |= CONFIG_VERDICT), (c._flags &= ~REACTIVE_LANE_DIRTY));
if (t?._lane ? sameLane(t, l) : (c as any)._type) c._flags |= REACTIVE_LANE_DIRTY;
Expand Down
180 changes: 180 additions & 0 deletions packages/signals/tests/optimistic-second-guess-reask-4629.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* #4629 (the memo half): an async list `issues`, a `createOptimistic` log of
* assignments overlaid on it by a memo. Action 1 is accepted and its
* `refresh(issues)` is in flight when action 2 guesses into the same log.
*
* `issues` is not derived from the guess, so F-6's lane hold does not apply:
* what lane work displays is the screen plus the lane's own values, and a
* plain read in lane work of a node the parent holds is the screen's value
* (D-26, clarified 2026-10-28). Action 1's re-ask is a `refresh()`, which
* re-asks the same question and holds nothing new (D-34, D-31). The two
* guesses share the log slot, so they entangle (D-33): the log reverts only
* when the last of them settles, and a rejected second action reverts only
* its own guess.
*/
import { describe, expect, it } from "vitest";
import {
action,
createMemo,
createOptimistic,
createRenderEffect,
createRoot,
createSignal,
flush,
mapArray,
refresh
} from "../src/index.js";

type Row = { id: number; a: string };

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

type Read = "issuesFirst" | "guessFirst" | "viaMemo" | "viaMemoFirst";

function run(read: Read = "issuesFirst") {
const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = [];
const ask = (key: string) => new Promise<any>((res, rej) => asks.push({ key, res, rej }));
const settle = async (key: string, v: any, fail = false) => {
const i = asks.findIndex(a => a.key === key);
const a = asks.splice(i, 1)[0];
fail ? a.rej(v) : a.res(v);
await tick();
};
const server: Row[] = [
{ id: 3, a: "-" },
{ id: 4, a: "dana" }
];
let screen = "";
let assign!: (id: number) => any;
createRoot(() => {
const issues = createMemo<Row[]>(() => ask("issues"));
const [truth] = createSignal<Row[]>(() => []);
const [assigning, guess] = createOptimistic<Row[]>(() => truth());
// The guess read straight, or through a memo derived from it (the held
// pass is then a grandchild of the guessed node).
const byId = createMemo(() => {
const m: Record<number, string> = {};
for (const x of assigning()) m[x.id] = x.a;
return m;
});
const guessed = () => {
if (read === "viaMemo" || read === "viaMemoFirst") return byId();
const m: Record<number, string> = {};
for (const x of assigning()) m[x.id] = x.a;
return m;
};
const overlay = createMemo(() => {
const first = read === "guessFirst" || read === "viaMemoFirst" ? guessed() : undefined;
return issues().map(i => {
const a = (first ?? guessed())[i.id];
return a ? { ...i, a } : i;
});
});
const rows = mapArray(overlay, (r: Row) => () => `[#${r.id} ${r.a}]`);
createRenderEffect(
() =>
rows()
.map(f => f())
.join(""),
v => void (screen = v)
);
assign = action(function* (id: number) {
guess(xs => [...xs, { id, a: "guest" }]);
try {
yield ask(`assign:${id}`);
} catch {
return;
}
refresh(issues);
});
});
return {
get screen() {
return screen;
},
settle,
server,
assign: (id: number) => assign(id),
snap: () => server.map(x => ({ ...x }))
};
}

async function toSecondGuess(read?: Read) {
const t = run(read);
flush();
await tick();
await t.settle("issues", t.snap());
expect(t.screen).toBe("[#3 -][#4 dana]");
t.assign(3);
await tick();
expect(t.screen).toBe("[#3 guest][#4 dana]");
t.server[0].a = "guest";
await t.settle("assign:3", {});
// action 1's refresh(issues) is in flight
expect(t.screen).toBe("[#3 guest][#4 dana]");
t.assign(4);
await tick();
return t;
}

describe("#4629: a second guess into a log beside the first action's re-ask (memo)", () => {
it("the guess shows at once, beside the screen's issues (D-26), and stays", async () => {
const t = await toSecondGuess();
// (4)
expect(t.screen).toBe("[#3 guest][#4 guest]");
await t.settle("issues", t.snap());
// (5) action 1's re-ask lands; action 2's guess still stands
expect(t.screen).toBe("[#3 guest][#4 guest]");
t.server[1].a = "guest";
await t.settle("assign:4", {});
await t.settle("issues", t.snap());
// (6) both actions settled on the truth
expect(t.screen).toBe("[#3 guest][#4 guest]");
});

// D-28: whether a read throws is the read's own — not the order of the
// reads around it. Reading the log before `issues` showed the guess at
// once already.
it("either read order, read straight or through a memo: the guess shows at once", async () => {
for (const read of ["issuesFirst", "guessFirst", "viaMemo", "viaMemoFirst"] as const) {
const t = await toSecondGuess(read);
expect(t.screen, read).toBe("[#3 guest][#4 guest]");
await t.settle("issues", t.snap());
expect(t.screen, read).toBe("[#3 guest][#4 guest]");
}
});

it("through a memo, a rejected second action reverts its row", async () => {
const t = await toSecondGuess("viaMemo");
expect(t.screen).toBe("[#3 guest][#4 guest]");
await t.settle("issues", t.snap());
await t.settle("assign:4", new Error("no"), true);
expect(t.screen).toBe("[#3 guest][#4 dana]");
});

it("a rejected second action reverts its row", async () => {
const t = await toSecondGuess();
expect(t.screen).toBe("[#3 guest][#4 guest]");
await t.settle("issues", t.snap());
expect(t.screen).toBe("[#3 guest][#4 guest]");
await t.settle("assign:4", new Error("no"), true);
expect(t.screen).toBe("[#3 guest][#4 dana]");
});

// D-33: the two guesses share the log slot, so the slot reverts only when
// the last of the two actions settles — action 1 is out until its re-ask
// lands (D-35: a plain load the action asked for is still in flight).
it("a second action rejected before action 1's re-ask lands reverts with it", async () => {
const t = await toSecondGuess();
expect(t.screen).toBe("[#3 guest][#4 guest]");
await t.settle("assign:4", new Error("no"), true);
expect(t.screen).toBe("[#3 guest][#4 guest]");
await t.settle("issues", t.snap());
expect(t.screen).toBe("[#3 guest][#4 dana]");
});
});
9 changes: 4 additions & 5 deletions packages/signals/tests/projection-second-guess-4629.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
* `issues` is not derived from the guess, so F-6's lane hold does not apply:
* a plain read in lane work of a node the parent holds is the screen's value
* (D-26, clarified 2026-10-28), and the lane displays the screen plus its own
* guesses — `[#4 guest]` now, in both shapes. Today both shapes hold the
* guess until action 1's re-ask lands (`[#4 dana]`): a known deviation,
* pinned `it.fails` below.
* guesses — `[#4 guest]` now, in both shapes. (Before #4637 both shapes held
* the guess until action 1's re-ask landed: the overlay's pass threw on its
* first, pending read before it reached the guess, D-28.)
*
* (5) Through the projection the guess never showed before the server's
* answer: the derive's pass that ran as the landing's (the frame's) work and
Expand Down Expand Up @@ -137,8 +137,7 @@ describe("#4629: a second guess into a log while the first action's refresh is i
expect([f5, f6]).toEqual(["[#3 guest][#4 guest]", "[#3 guest][#4 guest]"]);
});

// Known deviation (D-26): the guess waits on action 1's re-ask.
it.fails(`${name}: the guess shows at once, beside the screen's issues (D-26)`, async () => {
it(`${name}: the guess shows at once, beside the screen's issues (D-26)`, async () => {
expect(await frames(proj)).toEqual([
"[#3 guest][#4 guest]",
"[#3 guest][#4 guest]",
Expand Down
Loading