From 844b3def6002fea8889c496098b9ab967ba1997b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:41:49 +0000 Subject: [PATCH 1/3] fix(signals): seat a held pending reader of a re-guess in the lane from its first read (#4629) A body-end correction re-homes an overlay memo into its action's hold, pending on the action's refresh. A second action's guess into the same slot opened a new lane, but the overlay's re-run started as the hold's work and threw on the pending list before reaching the guess read that would have moved it into the lane. Readers the lane's parent holds pending are now listed in the lane before the pass, so lane work reads the parent's flight as the screen (D-26, D-37) in either read order (D-28). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- .../optimistic-second-guess-reask-4629.md | 5 + packages/signals/src/core/lanes.ts | 19 ++- ...optimistic-second-guess-reask-4629.test.ts | 155 ++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 .changeset/optimistic-second-guess-reask-4629.md create mode 100644 packages/signals/tests/optimistic-second-guess-reask-4629.test.ts diff --git a/.changeset/optimistic-second-guess-reask-4629.md b/.changeset/optimistic-second-guess-reask-4629.md new file mode 100644 index 000000000..dbf5f3314 --- /dev/null +++ b/.changeset/optimistic-second-guess-reask-4629.md @@ -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 (#4629). diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index f339aab2b..be69fd5d6 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -263,11 +263,28 @@ function applyGuesses(parent: Transaction | null): void { if (parent !== null && holder !== null) merge(parent, holder); ext(n)._q = pendingGuesses[i + 2]; if (changed) { + const l = txOf(n); + // 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 from its first read: 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) — seated at the guess read, + // the earlier read threw the pass back into the hold (#4629). + for (let s = n._subs; s !== null; s = s._nextSub) { + const c = s._sub; + if ( + !(c as any)._type && + (c._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && + (c._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) === STATUS_PENDING && + txOf(c) === resolveTx(l._parent!) + ) + list(c, l); + } // The lane's members re-derive as its work (REACTIVE_LANE_DIRTY): a // blocked lane's re-guess does not re-run its render effects as stale // readers republishing the committed view (#3460). insertSubs(n); - laneDirty(n, txOf(n)); + laneDirty(n, l); } } pendingGuesses.length = 0; diff --git a/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts b/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts new file mode 100644 index 000000000..7e2829f44 --- /dev/null +++ b/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts @@ -0,0 +1,155 @@ +/** + * #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(); + } +}; + +function run(guessFirst = false) { + const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = []; + const ask = (key: string) => new Promise((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(() => ask("issues")); + const [truth] = createSignal(() => []); + const [assigning, guess] = createOptimistic(() => truth()); + const overlay = createMemo(() => { + const log = guessFirst ? assigning() : undefined; + return issues().map(i => { + const g = (log ?? assigning()).filter(x => x.id === i.id).pop(); + return g ? { ...i, a: g.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(guessFirst?: boolean) { + const t = run(guessFirst); + 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: the guess shows at once", async () => { + for (const guessFirst of [false, true]) { + const t = await toSecondGuess(guessFirst); + expect(t.screen).toBe("[#3 guest][#4 guest]"); + } + }); + + 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]"); + }); +}); From da63fdcd9e8c7b2492a6961d6b41ffa7da480e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:21:48 +0000 Subject: [PATCH 2/3] test(signals): #4629 frame (4) now passes in both shapes With #4633 (projection) and this fix (memo), the second guess shows at once beside the screen's issues (D-26): the it.fails rows become plain rows and the D-26 known-deviation note becomes a fixed note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- packages/signals/docs/RULES-DERIVED.md | 2 +- .../signals/tests/projection-second-guess-4629.test.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 76fc5f977..88b32d180 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -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 diff --git a/packages/signals/tests/projection-second-guess-4629.test.ts b/packages/signals/tests/projection-second-guess-4629.test.ts index eaeff5cb0..ba18b3f6d 100644 --- a/packages/signals/tests/projection-second-guess-4629.test.ts +++ b/packages/signals/tests/projection-second-guess-4629.test.ts @@ -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 @@ -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]", From 732ca3fcd97ea525e72d4a5b96ed27ec0247cd8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:56:48 +0000 Subject: [PATCH 3/3] fix(signals): seat the held re-ask reader at every lane node's change (#4629) The seating loop ran only over the guessed node's direct subscribers, so a held pending pass reading the guess through a derived memo (byId) was still thrown back into the hold at frame (4). Moved into laneDirty, which runs at the guess and at each lane node's change (core and store), so the pass is seated whatever depth it reads the guess at (D-26, D-37, D-28). Smaller than the direct-only loop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- .../optimistic-second-guess-reask-4629.md | 2 +- packages/signals/src/core/lanes.ts | 19 +------- packages/signals/src/core/scheduler.ts | 21 ++++++++- ...optimistic-second-guess-reask-4629.test.ts | 45 ++++++++++++++----- 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/.changeset/optimistic-second-guess-reask-4629.md b/.changeset/optimistic-second-guess-reask-4629.md index dbf5f3314..83fb89f3f 100644 --- a/.changeset/optimistic-second-guess-reask-4629.md +++ b/.changeset/optimistic-second-guess-reask-4629.md @@ -2,4 +2,4 @@ "@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 (#4629). +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). diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index be69fd5d6..f339aab2b 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -263,28 +263,11 @@ function applyGuesses(parent: Transaction | null): void { if (parent !== null && holder !== null) merge(parent, holder); ext(n)._q = pendingGuesses[i + 2]; if (changed) { - const l = txOf(n); - // 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 from its first read: 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) — seated at the guess read, - // the earlier read threw the pass back into the hold (#4629). - for (let s = n._subs; s !== null; s = s._nextSub) { - const c = s._sub; - if ( - !(c as any)._type && - (c._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && - (c._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) === STATUS_PENDING && - txOf(c) === resolveTx(l._parent!) - ) - list(c, l); - } // The lane's members re-derive as its work (REACTIVE_LANE_DIRTY): a // blocked lane's re-guess does not re-run its render effects as stale // readers republishing the committed view (#3460). insertSubs(n); - laneDirty(n, l); + laneDirty(n, txOf(n)); } } pendingGuesses.length = 0; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 6cac72942..342d8669d 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -1426,8 +1426,25 @@ export function insertSubs(node: Signal | Computed): void { * `laneRead` re-derives it on the screen if that lane holds (`entered`). */ export function laneDirty(node: Signal | Computed, 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; diff --git a/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts b/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts index 7e2829f44..2360710f6 100644 --- a/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts +++ b/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts @@ -34,7 +34,9 @@ const tick = async () => { } }; -function run(guessFirst = false) { +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((res, rej) => asks.push({ key, res, rej })); const settle = async (key: string, v: any, fail = false) => { @@ -53,11 +55,24 @@ function run(guessFirst = false) { const issues = createMemo(() => ask("issues")); const [truth] = createSignal(() => []); const [assigning, guess] = createOptimistic(() => 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 = {}; + for (const x of assigning()) m[x.id] = x.a; + return m; + }); + const guessed = () => { + if (read === "viaMemo" || read === "viaMemoFirst") return byId(); + const m: Record = {}; + for (const x of assigning()) m[x.id] = x.a; + return m; + }; const overlay = createMemo(() => { - const log = guessFirst ? assigning() : undefined; + const first = read === "guessFirst" || read === "viaMemoFirst" ? guessed() : undefined; return issues().map(i => { - const g = (log ?? assigning()).filter(x => x.id === i.id).pop(); - return g ? { ...i, a: g.a } : i; + const a = (first ?? guessed())[i.id]; + return a ? { ...i, a } : i; }); }); const rows = mapArray(overlay, (r: Row) => () => `[#${r.id} ${r.a}]`); @@ -89,8 +104,8 @@ function run(guessFirst = false) { }; } -async function toSecondGuess(guessFirst?: boolean) { - const t = run(guessFirst); +async function toSecondGuess(read?: Read) { + const t = run(read); flush(); await tick(); await t.settle("issues", t.snap()); @@ -125,13 +140,23 @@ describe("#4629: a second guess into a log beside the first action's re-ask (mem // 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: the guess shows at once", async () => { - for (const guessFirst of [false, true]) { - const t = await toSecondGuess(guessFirst); - expect(t.screen).toBe("[#3 guest][#4 guest]"); + 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]");