diff --git a/.changeset/until-confirmed-derivation.md b/.changeset/until-confirmed-derivation.md new file mode 100644 index 000000000..a07943a44 --- /dev/null +++ b/.changeset/until-confirmed-derivation.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`until()` through a derivation of an optimistic value resolves once the source confirms the guess (#3943). A derivation's optimistic value counts as the truth when every guess it depends on has been confirmed (its source produced a value equal to the guess) and none of its other inputs has a write still held by the action; nothing is recomputed. A guess made again after a confirmation, an unconfirmed guess the derivation reads, or a held write it reads keeps `until()` waiting, as before. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index ac86072b4..ce53961bc 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:213` | — | 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:249` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.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 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 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 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 treeshake.test.ts×4 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:221` | scheduler.ts×1 | 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×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | 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×7 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | 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… | | 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×19 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:149` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.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 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:408` | — | 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 79ec7e9cf..c45802f81 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -401,17 +401,47 @@ export function supersede(n: Signal | Computed, value: unknown, change if (l._shown) n._value = n._x!._lane; n._config &= ~(CONFIG_OVERRIDE | CONFIG_GUESS); n._x!._lane = NOT_PENDING; - n._pendingValue = value; holdNode(n, parent); - joinFuture(parent); if (n._subs !== null) insertSubs(n); } else { - n._pendingValue = value; n._config |= CONFIG_HELD; - joinFuture(parent); - for (let s = n._subs; s !== null; s = s._nextSub) - if (s._sub._config & CONFIG_AUTHORITATIVE) enqueueSub(s._sub); + // A confirmation is silent (A17); only truth readers are told. + GlobalQueue._confirmed?.(l); } + n._pendingValue = value; + joinFuture(parent); +} + +/** Truth readers (`until`, `refresh`; CONFIG_AUTHORITATIVE) — pay-for-use: + * the first one installs these on GlobalQueue (signals.ts `watch`). + * + * A truth reader's read of a lane node: the lane's value when it is the + * truth (#3943), else the truth staged beneath a guess, else the committed + * value. */ +export function truthRead(el: Signal | Computed): unknown { + return truth(el) + ? laneValueOf(el) + : el._config & CONFIG_GUESS && el._pendingValue !== NOT_PENDING + ? el._pendingValue + : el._value; +} +/** A guess confirmed: its lane's truth readers re-read — of the lane's + * derivations too, which may be the truth now (#3943). */ +export function truthConfirmed(l: Transaction): void { + for (let i = 0; i < l._nodes.length; i++) + for (let s = l._nodes[i]._subs; s !== null; s = s._nextSub) + if (s._sub._config & CONFIG_AUTHORITATIVE) enqueueSub(s._sub); +} +/** A lane node's value is the truth when every guess beneath it is + * confirmed — its source staged a value equal to the guess — and nothing + * beneath it is held in the parent: same inputs, same value. */ +function truth(n: Signal | Computed): unknown { + if (n._config & CONFIG_GUESS) + return n._pendingValue !== NOT_PENDING && n._equals && n._equals(n._x!._lane, n._pendingValue); + if (n._pendingValue !== NOT_PENDING) return; + if (n._config & CONFIG_OVERRIDE) + for (let d = (n as Computed)._deps; d; d = d._nextDep) if (!truth(d._dep)) return; + return 1; } /** A lane's end, two ways. `into === null`: the parent landed — the lane's @@ -548,8 +578,7 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal< * member throws like any. */ export function laneRead(c: Computed | null, el: Signal | Computed): unknown { const guess = el._config & CONFIG_GUESS; - if (c !== null && c._config & CONFIG_AUTHORITATIVE) - return guess && el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; + if (c !== null && c._config & CONFIG_AUTHORITATIVE) return GlobalQueue._truth!(el); if (c === null) return laneValueOf(el); const l = txOf(el); const status = (el as Computed)._statusFlags; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 8a464b02b..37ed8b68f 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -640,6 +640,10 @@ export class GlobalQueue implements IQueue { /** The store's row judgment for a guessed slot (optimistic.ts): the truth * is the guess's echo when reconcile would write nothing into it. */ declare static _echoes: ((guess: unknown, truth: unknown) => boolean) | undefined; + /** Truth readers (lanes.ts `truthRead`/`truthConfirmed`), installed by the + * first `until`/`refresh` (signals.ts `watch`). */ + declare static _truth: ((el: any) => unknown) | undefined; + declare static _confirmed: ((l: Transaction) => void) | undefined; /** `affects()` on a store (store/affects.ts): a store's node for a key * (store/store.ts `getNode`); a bare registration (birth inheritance); a * witnessed mark on an untracked probe (verdict.ts). */ diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index a51db7e43..7a7e28e5d 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -31,6 +31,7 @@ import { dirtyQueue, flushTransaction, globalQueue, + GlobalQueue, joinFuture, passTx, resolveTx, @@ -39,7 +40,7 @@ import { } from "./core/scheduler.js"; import { REACTIVE_JOINED, STATUS_ERROR, STATUS_PENDING } from "./core/constants.js"; import { unwrapStatusError } from "./core/error.js"; -import { optimisticWrite } from "./core/lanes.js"; +import { optimisticWrite, truthConfirmed, truthRead } from "./core/lanes.js"; /** * Low-level reactive-cleanup primitive. Registers a callback that runs when @@ -766,6 +767,10 @@ function watch( inPass?: (v: T) => void, own: Transaction | null = flushTransaction ): void { + if (config & CONFIG_AUTHORITATIVE) { + GlobalQueue._truth = truthRead; + GlobalQueue._confirmed = truthConfirmed; + } // `config` is on the node for its first pass too (the pass runs inside // `computed()`): `until`'s predicate is authoritative from its first read, // or an already-displayed guess would satisfy it in a later slice. (REDERIVE diff --git a/packages/signals/tests/until-memo-confirmation-3943.test.ts b/packages/signals/tests/until-memo-confirmation-3943.test.ts new file mode 100644 index 000000000..a0a843951 --- /dev/null +++ b/packages/signals/tests/until-memo-confirmation-3943.test.ts @@ -0,0 +1,243 @@ +import { expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createOptimisticStore, + createRenderEffect, + createRoot, + createSignal, + flush, + until +} from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + flush(); +}; + +for (const via of ["direct", "memo"] as const) + it(`until() ${via} resolves on the source confirming the guess (#3943)`, async () => { + let setSource!: (v: number) => void; + let save!: () => Promise; + createRoot(() => { + const [source, ss] = createSignal(0); + setSource = ss; + const [count, guess] = createOptimistic(() => source()); + const doubled = createMemo(() => count() * 2); + createRenderEffect(count, () => {}); + createRenderEffect(doubled, () => {}); + save = action(function* () { + guess(1); + yield Promise.resolve(); + yield until(() => (via === "memo" ? doubled() === 2 : count() === 1)); + }); + }); + flush(); + let done = false; + save().then(() => (done = true)); + await tick(); + expect(done).toBe(false); + setSource(1); + flush(); + await tick(); + expect(done).toBe(true); + }); + +function setup() { + let setSource!: (v: number) => void; + let setX!: (v: number) => void; + let api!: { + count: () => number; + doubled: () => number; + sum: () => number; + x: () => number; + guess: (v: number) => void; + }; + createRoot(() => { + const [source, ss] = createSignal(0); + const [x, sx] = createSignal(0); + setSource = ss; + setX = sx; + const [count, guess] = createOptimistic(() => source()); + const doubled = createMemo(() => count() * 2); + const sum = createMemo(() => count() + x()); + createRenderEffect(count, () => {}); + createRenderEffect(doubled, () => {}); + createRenderEffect(sum, () => {}); + api = { count, doubled, sum, x, guess }; + }); + flush(); + return { setSource, setX, ...api }; +} + +const watch = (p: Promise) => { + const s = { done: false }; + p.then(() => (s.done = true)); + return s; +}; + +// Review of #3978, 1: a truth staged beneath an earlier guess does not +// confirm the next guess — confirmed means the staged truth equals the guess. +for (const via of ["direct", "memo"] as const) { + it(`until() ${via}: a re-guess the source has not confirmed keeps waiting (two saves)`, async () => { + const m = setup(); + const save = action(function* (n: number) { + m.guess(n); + yield Promise.resolve(); + yield until(() => (via === "memo" ? m.doubled() === n * 2 : m.count() === n)); + }); + const first = watch(save(1)); + await tick(); + m.setSource(1); + flush(); + await tick(); + expect(first.done).toBe(true); + + const second = watch(save(2)); + await tick(); + expect(second.done).toBe(false); + m.setSource(2); + flush(); + await tick(); + expect(second.done).toBe(true); + }); + + it(`until() ${via}: one action guessing twice waits for the second confirmation`, async () => { + const m = setup(); + const save = action(function* () { + m.guess(1); + yield Promise.resolve(); + yield until(() => m.count() === 1); + m.guess(2); + yield Promise.resolve(); + yield until(() => (via === "memo" ? m.doubled() === 4 : m.count() === 2)); + }); + const s = watch(save()); + await tick(); + m.setSource(1); + flush(); + await tick(); + expect(s.done).toBe(false); + m.setSource(2); + flush(); + await tick(); + expect(s.done).toBe(true); + }); +} + +// Review of #3978, 2: a derivation that also reads a held plain write is not +// the truth — its lane value (guess + the old `x`) never commits. +it("until() does not resolve on a derivation mixing a confirmed guess with a held write", async () => { + const m = setup(); + const seen: string[] = []; + const run = action(function* () { + m.setX(10); + m.guess(1); + yield Promise.resolve(); + yield until(() => m.x() === 10); + seen.push("x"); + yield until(() => m.sum() === 1); + seen.push("sum=1"); + }); + run(); + await tick(); + m.setSource(1); + flush(); + await tick(); + expect(seen).toEqual(["x"]); +}); + +// Review of #3978, 3: confirmation is per dependency — an unconfirmed guess +// elsewhere in the action does not block a derivation that does not read it. +it("until() on a derivation of a confirmed guess ignores another, unconfirmed guess", async () => { + let setA!: (v: number) => void; + let save!: () => Promise; + createRoot(() => { + const [a, sa] = createSignal(0); + const [b] = createSignal(0); + setA = sa; + const [optA, guessA] = createOptimistic(() => a()); + const [optB, guessB] = createOptimistic(() => b()); + const doubledA = createMemo(() => optA() * 2); + createRenderEffect(doubledA, () => {}); + createRenderEffect(optB, () => {}); + save = action(function* () { + guessA(1); + guessB(1); + yield Promise.resolve(); + yield until(() => doubledA() === 2); + }); + }); + flush(); + const s = watch(save()); + await tick(); + expect(s.done).toBe(false); + setA(1); + flush(); + await tick(); + expect(s.done).toBe(true); +}); + +// The store form of review 1: a field edited twice in one action waits for +// the second confirmation. +it("until() on an optimistic store field edited twice waits for the second confirmation", async () => { + let setSource!: (v: string) => void; + let save!: () => Promise; + createRoot(() => { + const [source, ss] = createSignal(""); + setSource = ss; + const [store, setStore] = createOptimisticStore(() => ({ text: source() }), { text: "" }); + const upper = createMemo(() => store.text.toUpperCase()); + createRenderEffect(upper, () => {}); + save = action(function* () { + setStore(s => void (s.text = "a")); + yield Promise.resolve(); + yield until(() => store.text === "a"); + setStore(s => void (s.text = "b")); + yield Promise.resolve(); + yield until(() => upper() === "B"); + }); + }); + flush(); + const s = watch(save()); + await tick(); + setSource("a"); + flush(); + await tick(); + expect(s.done).toBe(false); + setSource("b"); + flush(); + await tick(); + expect(s.done).toBe(true); +}); + +// A store row the server sends back as a fresh object confirms by content +// (the row echoes the guess, #3898), so a derivation over it is the truth too. +it("until() on a derivation of an optimistic store row confirmed by a fresh, equal row", async () => { + type Row = { id: string; text: string }; + let setSource!: (v: Row[]) => void; + let save!: () => Promise; + createRoot(() => { + const [source, ss] = createSignal([]); + setSource = ss; + const [store, setStore] = createOptimisticStore(() => ({ items: source() }), { + items: [] as Row[] + }); + const texts = createMemo(() => store.items.map(i => i.text).join(",")); + createRenderEffect(texts, () => {}); + save = action(function* () { + setStore(s => void s.items.push({ id: "1", text: "a" })); + yield Promise.resolve(); + yield until(() => texts() === "a"); + }); + }); + flush(); + const s = watch(save()); + await tick(); + expect(s.done).toBe(false); + setSource([{ id: "1", text: "a" }]); + flush(); + await tick(); + expect(s.done).toBe(true); +}); diff --git a/packages/web/test/ispending-guard-born-held.spec.tsx b/packages/web/test/ispending-guard-born-held.spec.tsx index 749c2b8da..eb06dc013 100644 --- a/packages/web/test/ispending-guard-born-held.spec.tsx +++ b/packages/web/test/ispending-guard-born-held.spec.tsx @@ -18,7 +18,6 @@ for (const shape of ["show", "show+loading", "direct"] as const) const container = document.createElement("div"); const [x, setX] = createSignal(0); const [open, setOpen] = createSignal(shape === "direct"); - let mountDirect!: () => void; const [direct, setDirect] = createSignal(false); const Form = () => { const data = createMemo(() => x()); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 04ee1d38d..6695fcdb8 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3304,8 +3304,18 @@ module.exports = [ // into it while it is live (`liveTx`, `holdNode`). Cap set at measured + 10 // B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-07 on the // condition hello world stays under 10 KB. - limit: "28.89 KB", - capMinified: 86911, + // Re-base (until() through a derivation of confirmed guesses, #3943, + // 2026-10-11): 28.89 KB -> 28.91 KB, measured at 28,897 B by CI (Size run + // 38127091137) against the PR's base, `next` @ c51c5c95c's 28,841 (+56 B; + // 7 B over the cap; +0 B minified, 86,937 -> 86,937; recorded 86,911 -> + // 86,937). The PR adds nothing minified here — the truth read sits behind + // GlobalQueue hooks the first `until`/`refresh` installs — and the brotli + // move is layout. The 26 B over the recorded minified was already on + // `next` (+12 B before #3977, +14 B from it), which had used up the 20 B + // allowance. Cap set at measured + 10 B rounded up to 0.01 KB. Accepted + // by the maintainer 2026-10-11. + limit: "28.91 KB", + capMinified: 86937, alias: observeAlias }, // Compiled-template scenarios (2026-10-05): the four `app:` fixtures above