diff --git a/.changeset/optimistic-second-guess-reask-4629.md b/.changeset/optimistic-second-guess-reask-4629.md new file mode 100644 index 000000000..83fb89f3f --- /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, and whether it reads the guess directly or through a memo derived from it (#4629). diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 472e6125f..2f808dcc3 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/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 new file mode 100644 index 000000000..2360710f6 --- /dev/null +++ b/packages/signals/tests/optimistic-second-guess-reask-4629.test.ts @@ -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((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()); + // 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 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]"); + }); +}); 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]",