From 1db43fb34e13f2edf22f1ba4b543ce3cb12f96df Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 5 Nov 2026 11:56:49 -0800 Subject: [PATCH 1/2] fix(signals): a corrected guess keeps its action's hold open (#4690) When a refresh() from outside an action landed under a guess the screen showed, the correction re-homed the node into the action's hold, but nothing kept that hold open on the node's next flight, as an uncorrected guess does through guessFlights. A hold waits only on flights a render reader observes, so with nothing rendering the source the hold landed with the action's write: effects and untracked reads showed the re-ask's answer until the action's own refresh() landed, while rendered readers kept the guess (F-11, D-34). Corrected shown guesses are now marked and lanesBlocked keeps their hold open while the node is in flight; endLanes clears the marks at the landing. Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com> --- ...orrected-guess-waits-for-action-refresh.md | 5 + packages/signals/docs/RULES-DERIVED.md | 2 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/lanes.ts | 31 +- .../optimistic-outside-reask-4690.test.ts | 405 ++++++++++++++++++ 5 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 .changeset/optimistic-corrected-guess-waits-for-action-refresh.md create mode 100644 packages/signals/tests/optimistic-outside-reask-4690.test.ts diff --git a/.changeset/optimistic-corrected-guess-waits-for-action-refresh.md b/.changeset/optimistic-corrected-guess-waits-for-action-refresh.md new file mode 100644 index 000000000..f297b3bf9 --- /dev/null +++ b/.changeset/optimistic-corrected-guess-waits-for-action-refresh.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An optimistic guess corrected by an outside re-ask now stays the value for effects and untracked reads until its action's own refresh lands (#4690). When a `refresh()` from outside the action landed under a pending guess (`createOptimistic`, or a `createOptimisticStore` keyed, unkeyed or object-shaped), the action's hold stopped waiting on the guessed source's next flight. Where nothing rendered the source, the hold landed with the action's write, and effects and untracked reads showed the old truth until the action's `refresh()` answered, while rendered readers (a render effect, a text node under ``, an effect beside one) kept the guess. Rendered output never flickered; effect-only readers disagreed with it. A shown guess that was corrected now keeps its hold open while its own source is in flight, as an uncorrected guess does. diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 07704490f..a6f3386ff 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -298,7 +298,7 @@ A keyed store's arrangement guess (rows by key) that the screen showed is not vo A new question is not the guess's own source answering. When a guess's node re-asks because an input changed outside the guess's own action (a plain `setSel(1)` while a post that guessed over `sel() = 3` is out), the guess cannot stand beside the new input — that frame would show the new input over the old question's answer and the guess (F-1) — so the frame that changed the input is held with the guess's action (2026-10-28, #4336). The screen keeps the old input and the guess until that action commits, then shows the new input with its answer as one frame, however the reads are grouped into render effects (#4284). Meanwhile `isPending` reads `true` while the new question is in flight or held (F-9), `latest()` answers the newest truth (F-6), and untracked reads see the screen. A `refresh()` re-asks the same question and holds nothing new; the guess's own action changing the input is the correction above. A store's derive is one node asking one question for every key, so the hold is decided where the guess is read: a frame still reading a guess when the flush ends is held with it, and a read of a key with no guess is an ordinary pending read (F-2) — `comments[1]` waits for issue 1's answer, not for a post that guessed on `comments[3]`. An input set through its own optimistic write asks the new question in its own lane: the other action's guess does not answer it, so the lane waits for its own answer (F-6) and then shows it, with the guessed node (for a store, every guessed slot of the target its derive writes) as the lane's derivation from there (F-11); the guess's readers wait with the lane, however the reads are grouped into render effects. The other action's guess was only displaced: when the input's action ends without writing the truth, its lane's world is void, the revert re-asks the old question as the revert's work, and the displaced guess comes back in the revert's frame over that answer — `#1: ` then `#3: dana | mine (sending)`, nothing between — and lives until its own action settles (D-33). If the input's action commits, the displaced guess is gone, as for any correction. -**Collapses:** [A18](./SPEC-ASYNC-SEMANTICS.md#a18-an-override-lives-exactly-as-long-as-its-own-transaction-a-newer-truth-from-the-source-supersedes-it-in-the-graph-immediately-on-screen-at-commit) supersession (#3331, 2026-09-09/10) and (d) before the first commit, "Correction display is what showed" (2026-10-05), lane contract 2, lane ruling 5, [A17](./SPEC-ASYNC-SEMANTICS.md#a17-an-active-override-is-the-displayed-value-until-its-transaction-commits-and-the-graphs-value-until-its-own-source-answers) (2026-09-09), OS-R39. **Status:** standing. +**Collapses:** [A18](./SPEC-ASYNC-SEMANTICS.md#a18-an-override-lives-exactly-as-long-as-its-own-transaction-a-newer-truth-from-the-source-supersedes-it-in-the-graph-immediately-on-screen-at-commit) supersession (#3331, 2026-09-09/10) and (d) before the first commit, "Correction display is what showed" (2026-10-05), lane contract 2, lane ruling 5, [A17](./SPEC-ASYNC-SEMANTICS.md#a17-an-active-override-is-the-displayed-value-until-its-transaction-commits-and-the-graphs-value-until-its-own-source-answers) (2026-09-09), OS-R39. **Status:** standing. Fixed 2026-11-05 (#4690): a shown guess a `refresh()` from outside its action corrected keeps the action's hold open while its own source is in flight, as an uncorrected guess does (`lanes.ts` `lanesBlocked`). With nothing rendering the source, the hold landed with the action's write, so effects and untracked reads showed the re-ask's answer until the action's own `refresh()` landed while rendered readers kept the guess — a tear between effect-only and rendered readers, not a flicker on screen; `tests/optimistic-outside-reask-4690.test.ts`. ### D-35. Provenance: an older question's answer never corrects a newer guess; the body's end corrects what nothing authoritative is answering diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index b0ec4f9ee..76956ee17 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -61,7 +61,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | | A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×2 constants.ts×1 core.ts×4 effect.ts×1 lanes.ts×7 scheduler.ts×18 seat.ts×1 verdict.ts×5 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 latest-memo-inline-parity.test.ts×1 latest-stale-branch-hold-4661.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 optimistic-lane-seam-loop-4643.test.ts×1 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 tracked-effect-staged-dep-4606-4607.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-memo-held-branch-4544.test.ts×1 verdict-probe-committed-4648.test.ts×2 verdict-shortcut-pending-source-liveness.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:223` | scheduler.ts×1 | caught-reactive-read-diagnostic.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 seat.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×2 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×8 scheduler.ts×2 seat.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×2 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-outside-reask-4690.test.ts×1 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×17 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | | A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | boundaries.ts×1 async.ts×1 constants.ts×1 core.ts×6 lanes.ts×1 scheduler.ts×2 seat.ts×1 types.ts×1 verdict.ts×6 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 errored-latest-obsolete-flight-4568.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 verdict-memo-flight-no-reask.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | | A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:410` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index 2140011b1..9d34da57d 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -164,9 +164,14 @@ let guessed = false; /** Guesses another action's lane took over (#4336): the node, its guess, * the guess's lane, its question, the lane that took it. */ const kept: any[] = []; -/** Lanes a revert dissolved: the guesses they took come back at their - * parent's landing (`endLanes`). */ -const reverted = /* @__PURE__ */ new WeakSet(); +/** Two kinds of member, one set (a lane is never a node). Lanes a revert + * dissolved: the guesses they took come back at their parent's landing + * (`endLanes`). Shown guesses a correction re-homed into their hold + * (`supersede`, `dissolveLane`): the slot's value for the screen and + * untracked reads until that hold lands (D-34), each keeps it open on its + * own flight as it did while a guess (`lanesBlocked`, #4690); the hold's + * landing clears them (`endLanes`). */ +const marked = /* @__PURE__ */ new WeakSet>(); /** Lane `l` takes `n`'s guess (another action's), kept to come back if `l` * reverts. Its readers saw the guess: they re-derive at the lane's reveal * (and, a flight's, hold the lane while they would show it). `d`: a guess @@ -526,10 +531,10 @@ export function supersede(n: Signal | Computed, value: unknown, change if (__OBSERVE__ && l._shown && attrHooks !== null) attrHooks.optimisticReverted(n, n._x!._lane, value, reverting ? "reverted" : "superseded"); dissolveLane(l, parent, n); - if (guessed && reverting) reverted.add(l); + if (guessed && reverting) marked.add(l); // What the screen showed stays the screen until the parent lands (A18 // (c): display and untracked reads keep the override until the commit). - if (l._shown) n._value = n._x!._lane; + if (l._shown) ((n._value = n._x!._lane), guessed && marked.add(n)); n._config &= ~(CONFIG_OVERRIDE | CONFIG_GUESS); n._x!._lane = NOT_PENDING; holdNode(n, parent); @@ -651,7 +656,7 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal< // re-base) over a truth staged on it is no derivation anything // re-derives: it lands that truth the same way (D-34, #4642). if (n._pendingValue === NOT_PENDING) n._pendingValue = covered(n); - if (l._shown) n._value = slot; + if (l._shown) ((n._value = slot), guessed && marked.add(n)); holdNode(n, into); insertSubs(n); } else { @@ -915,6 +920,7 @@ function laneSeam(l: Transaction, leaked: boolean): void { /** The parent landed: its lanes end, nested first. (None is blocked: a * blocked lane blocks its parent.) */ function endLanes(u: Transaction): void { + if (guessed) for (const n of u._nodes) marked.delete(n); // A revert landing (#4336): the guesses its lane took from another action // that is still out come back in this frame, over the truth it lands — // its readers re-derive with the landing (one frame, no bare truth @@ -925,7 +931,7 @@ function endLanes(u: Transaction): void { let [n, v, g, q, t] = kept.splice(i, 5); // (A store slot's node now: the one taken, or a read's since.) if ( - reverted.has(t) && + marked.has(t) && lanes.includes(g) && (!(n._config & CONFIG_SLOT_NODE) || (n = GlobalQueue._slotTake!(n, true))) ) { @@ -952,8 +958,17 @@ function endLanes(u: Transaction): void { * pending work derives from the frame's write through the guess, so the * frame is not complete without it (A15) — landing it would commit the write * beside a derivation still showing the old one. A guess whose own flight is - * up likewise: the question was asked in this frame. */ + * up likewise: the question was asked in this frame. So does a shown guess + * a correction re-homed into `t` (`marked`): still the screen's and the + * untracked reads' value (F-11, D-34), its flight — the action's `refresh()` + * after an outside re-ask corrected it — is the answer it waits for, as + * before the correction (D-7, D-35). With no render effect pending on that + * flight, `t` landed at the body's end: effects and untracked reads saw the + * re-ask's answer until the refresh landed, while rendered readers kept the + * guess (#4690). Not a lane's: guessed again there, its flight blocks the + * parent, not the lane (A17, `linkBlocked`). */ function lanesBlocked(t: Transaction): boolean { + if (guessed && !t._lane && t._nodes.some(n => marked.has(n) && inFlight(n))) return true; return (t._lane && linkBlocked(t)) || nestedBlocked(t); } /** Parents whose lane revealed this seam with readers to re-derive there diff --git a/packages/signals/tests/optimistic-outside-reask-4690.test.ts b/packages/signals/tests/optimistic-outside-reask-4690.test.ts new file mode 100644 index 000000000..e8179022c --- /dev/null +++ b/packages/signals/tests/optimistic-outside-reask-4690.test.ts @@ -0,0 +1,405 @@ +/** + * #4690: an action guesses, yields its write, then `refresh()`es the guessed + * source. A `refresh()` of that source from outside the action lands while + * the guess is pending: the same question, so it holds nothing new, and its + * answer corrects the graph now and the screen at the commit (D-34). The + * hold the guess was made over is the action's, open until its body has + * returned and everything it put in flight has landed — its own `refresh()` + * included (D-7, D-31) — so the guess is the slot's value for the screen and + * for untracked reads until that refresh lands (F-11, D-33; S-44 row 1′: + * "until the post commits"). The write landing ends the body, but the + * action's refresh is authoritative and in flight, so the body's end + * corrects nothing (D-35). Never the old truth between the write and the + * refresh — with the outside re-ask exactly as without it (the control). + * + * The corrected guess no longer kept its hold open on its own flight, as an + * uncorrected guess does (lanes.ts `guessFlights`): with no render effect + * pending on the refresh, the hold landed at the body's end and effects and + * untracked reads saw the old truth until the refresh's own landing, while a + * rendered reader — a render effect, a text node under ``, and an + * effect beside it — kept the guess: a tear between effect-only readers and + * rendered ones, not a flicker a rendered app shows. The corrected guess + * now keeps its hold open on its flight (lanes.ts `marked`, `lanesBlocked`) + * — for its own hold only, never a lane that guesses it again, and never + * past that hold's landing (the last two blocks). The unkeyed array store + * writes the answer into its rows; a derive that replaces the row objects + * instead is outside this file. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createOptimistic, + createEffect, + createLoadingBoundary, + createOptimisticStore, + createRenderEffect, + createRoot, + createSignal, + flush, + refresh, + untrack +} from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 4; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +function asks() { + const list: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => list.push({ key, res })); + const land = async (key: string, v: any) => { + const i = list.findIndex(a => a.key === key); + expect(i).toBeGreaterThan(-1); + list.splice(i, 1)[0].res(v); + await tick(); + }; + return { ask, land, list }; +} + +// The readers of #4690's corrected scope: effects or untracked reads alone +// saw the old truth between the write and the action's refresh; a text node +// under `` kept the guess, and so did an effect beside it. Every one +// shows the guess throughout (F-1: one world for every channel). +const readers = [ + "untracked reads only", + "an effect only", + "a render effect", + "a text node under ", + "an effect beside a text node under " +] as const; +type Reader = (typeof readers)[number]; + +function harness(read: () => string, reader: Reader) { + // Each reader's values, in the order it showed them. + const channels: string[][] = []; + const channel = () => { + const runs: string[] = []; + channels.push(runs); + return runs; + }; + if (reader === "an effect only" || reader === "an effect beside a text node under ") { + const runs = channel(); + createEffect(read, v => void runs.push(v)); + } + if (reader === "a render effect") { + const runs = channel(); + createRenderEffect(read, v => void runs.push(v)); + } + if (reader.includes("")) { + // The screen: the fallback, or the text node's value once content shows. + const runs = channel(); + let content = false; + let text = ""; + const b = createLoadingBoundary( + () => { + createRenderEffect(read, v => { + text = v; + if (content) runs.push(v); + }); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + content = v === "content"; + runs.push(content ? text : v); + }); + } + const last = (runs: string[]) => runs[runs.length - 1] ?? null; + return { + // What every reader shows now, then the slot's untracked value. + shows: () => [...channels.map(last), untrack(read)], + // The distinct values each reader showed, in order. + histories: () => channels.map(runs => runs.filter((v, i) => v !== runs[i - 1])) + }; +} + +function signalForm(reader: Reader) { + const { ask, land, list } = asks(); + let h!: ReturnType; + let rename!: (title: string) => any; + let reask!: () => void; + createRoot(() => { + const [truth] = createSignal<{ title: string }>(async () => ({ ...(await ask("fetch")) })); + const [detail, setGuess] = createOptimistic(() => truth()); + h = harness(() => detail().title, reader); + rename = action(function* (title: string) { + setGuess(d => ({ ...d, title })); + yield ask("rename"); + refresh(truth); + }); + reask = () => refresh(truth); + }); + return { land, list, h, rename: (t: string) => rename(t), reask: () => reask() }; +} + +function storeForm(reader: Reader, key: "id" | null) { + const { ask, land, list } = asks(); + let h!: ReturnType; + let rename!: (title: string) => any; + let reask!: () => void; + createRoot(() => { + const [issues, setIssues] = createOptimisticStore<{ id: number; title: string }[]>( + async d => { + const r = await ask("fetch"); + d.length = r.length; + // Unkeyed rows have no identity to match by: the derive writes the + // answer into the rows it has, as an index-by-index merge does. + r.forEach((x: any, i: number) => { + if (key === null && d[i]) Object.assign(d[i], x); + else d[i] = { ...x }; + }); + }, + [], + { key } + ); + h = harness(() => issues.map(i => i.title).join(","), reader); + rename = action(function* (title: string) { + setIssues(d => { + d[0].title = title; + }); + yield ask("rename"); + refresh(issues); + }); + reask = () => refresh(issues); + }); + return { land, list, h, rename: (t: string) => rename(t), reask: () => reask() }; +} + +function objectStoreForm(reader: Reader) { + const { ask, land, list } = asks(); + let h!: ReturnType; + let rename!: (title: string) => any; + let reask!: () => void; + createRoot(() => { + const [detail, setDetail] = createOptimisticStore<{ title?: string }>(async d => { + d.title = (await ask("fetch")).title; + }, {}); + h = harness(() => detail.title!, reader); + rename = action(function* (title: string) { + setDetail(d => { + d.title = title; + }); + yield ask("rename"); + refresh(detail); + }); + reask = () => refresh(detail); + }); + return { land, list, h, rename: (t: string) => rename(t), reask: () => reask() }; +} + +const rows = (title: string) => [ + { id: 1, title }, + { id: 2, title: "Other" } +]; +const forms = { + "createOptimistic over createSignal(async)": { + make: signalForm, + answer: (title: string) => ({ title }), + show: (t: string) => t + }, + "keyed createOptimisticStore": { + make: (r: Reader) => storeForm(r, "id"), + answer: rows, + show: (t: string) => `${t},Other` + }, + "unkeyed array createOptimisticStore": { + make: (r: Reader) => storeForm(r, null), + answer: rows, + show: (t: string) => `${t},Other` + }, + "object createOptimisticStore": { + make: objectStoreForm, + answer: (title: string) => ({ title }), + show: (t: string) => t + } +}; + +for (const [name, f] of Object.entries(forms)) + for (const reader of readers) + describe(`#4690 the guess stays until the action's own refresh lands (${name}, ${reader})`, () => { + for (const outside of [true, false]) + it(`${outside ? "an outside re-ask landed under the guess" : "control: no outside re-ask"} (F-1, F-11, D-7, D-33, D-34, S-44 1′)`, async () => { + const t = f.make(reader); + const all = (title: string) => t.h.shows().map(() => f.show(title)); + flush(); + await tick(); + await t.land("fetch", f.answer("Old bug")); + expect(t.h.shows()).toEqual(all("Old bug")); + + t.rename("Bravo"); + await tick(); + expect(t.h.shows()).toEqual(all("Bravo")); + + if (outside) { + t.reask(); + await tick(); + await t.land("fetch", f.answer("Old bug")); + // The answer differs from the guess: every reader keeps the + // guess until the hold commits (D-34). + expect(t.h.shows()).toEqual(all("Bravo")); + } + + await t.land("rename", undefined); + // The action's refresh is in flight: its hold is still open. + expect(t.list.map(a => a.key)).toEqual(["fetch"]); + expect(t.h.shows()).toEqual(all("Bravo")); + + await t.land("fetch", f.answer("Bravo")); + expect(t.h.shows()).toEqual(all("Bravo")); + // No reader ever showed the old truth after the guess. + for (const history of t.h.histories()) + expect(history.filter(v => v !== "fallback")).toEqual([ + f.show("Old bug"), + f.show("Bravo") + ]); + }); + }); + +describe("#4690 a second guess in the corrected guess's lane", () => { + // One action guesses two slots (one lane); the outside re-ask corrects the + // first, which dissolves the lane: the second is the hold's to land, and + // its own refresh, asked by the action, keeps that hold open as it did + // while the lane stood (D-33: both live as long as the hold). + for (const outside of [true, false]) + it(`${outside ? "an outside re-ask corrected the other guess" : "control: no outside re-ask"} (F-11, D-7, D-33)`, async () => { + const { ask, land, list } = asks(); + let a!: () => { v: string }, b!: () => { v: string }; + let run!: () => any, reaskA!: () => void; + createRoot(() => { + const [truthA] = createSignal(async () => ({ v: await ask("A") })); + const [truthB] = createSignal(async () => ({ v: await ask("B") })); + let setA!: (v: { v: string }) => void, setB!: (v: { v: string }) => void; + [a, setA] = createOptimistic(() => truthA()); + [b, setB] = createOptimistic(() => truthB()); + run = action(function* () { + setA({ v: "a2" }); + setB({ v: "b2" }); + yield ask("save"); + refresh(truthB); + }); + reaskA = () => refresh(truthA); + }); + const shows = () => `${untrack(a).v} ${untrack(b).v}`; + flush(); + await tick(); + await land("A", "a1"); + await land("B", "b1"); + expect(shows()).toBe("a1 b1"); + run(); + await tick(); + expect(shows()).toBe("a2 b2"); + if (outside) { + reaskA(); + await tick(); + await land("A", "a1"); + expect(shows()).toBe("a2 b2"); + } + await land("save", undefined); + expect(list.map(x => x.key)).toEqual(["B"]); + expect(shows()).toBe("a2 b2"); + await land("B", "b2"); + // The hold commits: `a`'s truth (the re-ask's, or the value it + // covered), `b`'s refresh. + expect(shows()).toBe("a1 b2"); + }); +}); + +describe("#4690 the corrected guess holds only its own hold", () => { + // The hold the corrected guess kept open has landed: a later action that + // re-asks the source, with no guess and no reader, is judged exactly as on + // a node never guessed (F-11: the guess does not outlive its hold, D-33). + // A settled guess's final landing is itself a correction when it is a new + // object (supersede), so the control without the outside re-ask counts too. + async function laterAction(history: "outside re-ask" | "control" | "never guessed") { + const { ask, land } = asks(); + let detail!: () => { title: string }, x!: () => number; + let rename!: (t: string) => any, later!: () => any, reask!: () => void; + createRoot(() => { + const [truth] = createSignal(async () => ({ ...(await ask("fetch")) })); + let setGuess!: (fn: (d: { title: string }) => { title: string }) => void; + [detail, setGuess] = createOptimistic(() => truth()); + let setX!: (v: number) => void; + [x, setX] = createSignal(0); + rename = action(function* (title: string) { + setGuess(d => ({ ...d, title })); + yield ask("rename"); + refresh(truth); + }); + later = action(function* () { + refresh(truth); + yield ask("other"); + setX(1); + }); + reask = () => refresh(truth); + }); + flush(); + await tick(); + await land("fetch", { title: "Old bug" }); + if (history !== "never guessed") { + rename("Bravo"); + await tick(); + if (history === "outside re-ask") { + reask(); + await tick(); + await land("fetch", { title: "Old bug" }); + } + await land("rename", undefined); + await land("fetch", { title: "Bravo" }); + expect(untrack(detail).title).toBe("Bravo"); + } + later(); + await tick(); + await land("other", undefined); + const atBodyEnd = untrack(x); + await land("fetch", { title: "Charlie" }); + return [atBodyEnd, untrack(x), untrack(detail).title]; + } + + for (const history of ["outside re-ask", "control"] as const) + it(`a later action's refresh after the hold landed (${history}; F-11, D-33)`, async () => { + expect(await laterAction(history)).toEqual(await laterAction("never guessed")); + }); +}); + +describe("#4690 a new guess over the corrected guess", () => { + // The corrected guess's flight holds its hold, not a lane: a second rename + // guessing the slot again while the source is re-asked shows now, as any + // guess over a flight does (F-6, F-11; A17: the guess stands in for the + // flight) — the hold waits, the screen does not. + it("shows at once while the source is in flight (F-6, F-11)", async () => { + const { ask, land } = asks(); + const frames: string[] = []; + let rename!: (t: string) => any, reask!: () => void; + createRoot(() => { + const [truth] = createSignal(async () => ({ ...(await ask("fetch")) })); + const [detail, setGuess] = createOptimistic(() => truth()); + createRenderEffect( + () => detail().title, + v => void frames.push(v) + ); + rename = action(function* (title: string) { + setGuess(d => ({ ...d, title })); + yield ask(`rename ${title}`); + refresh(truth); + }); + reask = () => refresh(truth); + }); + flush(); + await tick(); + await land("fetch", { title: "Old bug" }); + rename("Bravo"); + await tick(); + reask(); + await tick(); + await land("fetch", { title: "Old bug" }); + reask(); + await tick(); + rename("Charlie"); + await tick(); + expect(frames).toEqual(["Old bug", "Bravo", "Charlie"]); + }); +}); From ebc7925908805706654b5d0a63d984c3ff63d4bc Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 5 Nov 2026 11:57:09 -0800 Subject: [PATCH 2/2] chore(size): re-record the store-family row for #4690 app: hydrating + every store primitive family: +110 B minified from #4690, measured locally over next @ fb672ca with the in-repo compiler build; cap = measured brotli + 10 B (33.42 -> 33.41 KB), recorded minified 100,437 -> 100,547 B. No floor moves. Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com> --- scripts/size/scenarios.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 216d132cc..3604ff409 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2799,11 +2799,12 @@ module.exports = [ // Batch re-record (2026-11-03, over next @ ab5b5cb, measured locally: #4667, #4669): 33.15 KB -> 33.26 KB, recorded minified 99,885 -> 99,898 B (+13 B), measured at 33,248 B brotli. The growth of #4667 and #4669 was accepted by the maintainer. Re-recorded once after the batch, per the batch process. // Batch re-record (2026-11-04, over next @ e704cfc, measured locally: #4674, #4676): 33.26 KB -> 33.27 KB, recorded minified 99,898 -> 99,967 B (+69 B), measured at 33,260 B brotli. The growth of #4674 and #4676 was accepted by the maintainer. Re-recorded once after the batch, per the batch process. // Re-record (2026-11-05, over next @ 5208944, measured locally: #4682, #4677): 33.27 KB -> 33.42 KB, recorded minified 99,967 -> 100,437 B (+470 B: #4677 +470 B; #4682 is size-neutral here), measured at 33,401 B brotli. Re-recorded in the fix's own change rather than in a batch, at the maintainer's request. - limit: "33.42 KB", + // Re-record (2026-11-05, over next @ fb672ca, measured locally: #4690): 33.42 KB -> 33.41 KB, recorded minified 100,437 -> 100,547 B (+110 B: #4690), measured at 33,398 B brotli. Re-recorded in the fix's own change rather than in a batch, at the maintainer's request. + limit: "33.41 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 97,045 -> 97,426 B (+240 B minified over next), measured locally (linux) at 31,976 B brotli (+109 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 100437, + capMinified: 100547, alias }, {