From 68e5cbb188e850044830df28388148f440e47f98 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 09:27:44 -0700 Subject: [PATCH] fix(signals): an optimistic store override survives its key becoming unobserved (S7; store follows signal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling 2026-09-17: store rules follow signal rules. Of the two divergences the 7-reader posture matrix recorded in #3520, this ships S7 and defers S6. S7 — the slot hook released a property node the moment its last subscriber left, with the optimistic override on it (overrides live on nodes over a clone the setter discards): s.n read 0 while the action was live, where an optimistic signal keeps its override whether or not anything reads it. A node carrying an override or a staged write now defers its release to the flush that resolves it (deferSlotRelease feeds the scheduler's transientStoreNodes sweep — present since the monorepo absorb with exactly these checks, and unfed). +55 B minified store. Pinned: override visible after gating away; node released at settle. S6 — A28 at the backing (a mainline setStore invisible to a derivation created inside a foreign action until the carrying flush) is ruled the same way but DEFERRED: done at the store twin it cost +402 B minified (a node born in the unflushed window must stage the write, and every arm existed twice — core's and the twin's). Pinned at the store's current value with the ruling attached; it closes when the store's value selection shares core's serve (DESIGN-CONSOLIDATION move 3b step 6). Ledger updated. Gate: suite green; oracles identical; matrix: only the S7 cells moved, to the signal's values; solid/web unchanged from next; no size caps exceeded. Co-authored-by: Claude via Cursor --- .../store-override-survives-unobserved.md | 5 ++ packages/signals/docs/DESIGN-CONSOLIDATION.md | 25 +++---- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/scheduler.ts | 16 +++-- packages/signals/src/store/next/store.ts | 6 ++ .../tests/posture-store-parity.test.ts | 70 +++++++++++++------ 6 files changed, 85 insertions(+), 39 deletions(-) create mode 100644 .changeset/store-override-survives-unobserved.md diff --git a/.changeset/store-override-survives-unobserved.md b/.changeset/store-override-survives-unobserved.md new file mode 100644 index 000000000..5d7ebbd09 --- /dev/null +++ b/.changeset/store-override-survives-unobserved.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An optimistic store override now survives its key becoming unobserved. The property node was released with the override on it the moment its last reader left, so an untracked read of the key (`s.n`) returned the committed value while the action was still live; the release now waits for the flush that resolves the override, as an optimistic signal keeps its override whether or not anything reads it. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md index f3afed9f7..f5c664f29 100644 --- a/packages/signals/docs/DESIGN-CONSOLIDATION.md +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -4,21 +4,22 @@ ## 0. Ledger (2026-09-17) -| Move | Status | Where | What it found | -| -------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 3a — `retireReporter`, deps scan at `_depsTail` | **merged** | #3496 and follow-ups (O3, fuzzer P1) | Fuzzer 984 / 4 / 12 → 994 / 0 / 6. | -| 3b step 1 — one `unflushed` | **merged** | #3510 (`CONFIG_ADOPTED_UNFLUSHED`) | Closed S1 / O4 for the verdict channels. | -| 3b step 1 — shared predicates | PR #3515 | `readerSeesCommitted` (read's slow tail), `visibleOverride` / one `hasActiveOverride` (core, lanes, verdict ×3, store ×2) | Pure refactor. Matrix, oracles, fuzzer identical; full bundle −72 B minified. | -| 3b step 2 — store node reads select by Rule 1 | PR #3516 | `nodeValue` → `readerSeesCommitted` behind the two store tunnels (`latest()`, `authoritativeServe`) | **S4:** the store's stale-of-foreign twin served committed but skipped the replay registration — a render effect's untracked read of a held key stayed on the pre-action value forever. Invisible to the suite, both oracles, the 621-cell matrix and the fuzzer. | -| 3b step 3 — backing-level replay | PR #3517 | `recordStaleReplay` (lifted from `heldFromStale`) called by `heldFromReader` and the adoption-hold view | S4's no-node and `reconcile`-inside-an-action forms (four store shapes, all failing on `next`). | -| 3b step 4 — A29 at the store's untracked paths | this PR | `nodeValue`, `pendingBackingVisible`, the adoption-hold view call `enterStagedRead` on the arm that serves the staged value; `enterStagedRead(null, txn)` for a backing | **S5:** a mainline memo's / user effect's untracked read of a held store key **published the unrevealed write** while the same read of a signal was born held. Matrix gained `memoUntracked` / `effectUntracked` reader kinds (621 → 851 cells) — the kind that sees it. | -| 3b — recorded, not ruled | open | `posture-store-parity` S6, S7 | **S6:** staged-ambient write, derivation created inside a foreign action: signal publishes committed (A28 / #3510), store publishes pending. **S7:** optimistic store override, only reader gated away: signal still reads the override, store reads committed. | -| 3b step 5 — `pendingBackingVisible`'s own arms, the store tunnels, `overrideRead` / `latestRead` / lane arms → one `serve` | not started | | Needs the lane ruling (§6). | -| 3c — cargo lifecycle | not started | | Wait for the first new "decided at the pass" fix, or for 3b to settle. | +| Move | Status | Where | What it found | +| -------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 3a — `retireReporter`, deps scan at `_depsTail` | **merged** | #3496 and follow-ups (O3, fuzzer P1) | Fuzzer 984 / 4 / 12 → 994 / 0 / 6. | +| 3b step 1 — one `unflushed` | **merged** | #3510 (`CONFIG_ADOPTED_UNFLUSHED`) | Closed S1 / O4 for the verdict channels. | +| 3b step 1 — shared predicates | PR #3515 | `readerSeesCommitted` (read's slow tail), `visibleOverride` / one `hasActiveOverride` (core, lanes, verdict ×3, store ×2) | Pure refactor. Matrix, oracles, fuzzer identical; full bundle −72 B minified. | +| 3b step 2 — store node reads select by Rule 1 | PR #3516 | `nodeValue` → `readerSeesCommitted` behind the two store tunnels (`latest()`, `authoritativeServe`) | **S4:** the store's stale-of-foreign twin served committed but skipped the replay registration — a render effect's untracked read of a held key stayed on the pre-action value forever. Invisible to the suite, both oracles, the 621-cell matrix and the fuzzer. | +| 3b step 3 — backing-level replay | PR #3517 | `recordStaleReplay` (lifted from `heldFromStale`) called by `heldFromReader` and the adoption-hold view | S4's no-node and `reconcile`-inside-an-action forms (four store shapes, all failing on `next`). | +| 3b step 4 — A29 at the store's untracked paths | this PR | `nodeValue`, `pendingBackingVisible`, the adoption-hold view call `enterStagedRead` on the arm that serves the staged value; `enterStagedRead(null, txn)` for a backing | **S5:** a mainline memo's / user effect's untracked read of a held store key **published the unrevealed write** while the same read of a signal was born held. Matrix gained `memoUntracked` / `effectUntracked` reader kinds (621 → 851 cells) — the kind that sees it. | +| 3b step 5 — store follows signal: S7 | PR #3521 (on #3520) | **Ruling 2026-09-17: "store rules should follow signal rules."** An optimistic override survives its key becoming unobserved (`deferSlotRelease` — the slot hook released the node with the override on it; the scheduler's transient-node sweep, unfed since the monorepo absorb, releases it when the override resolves). +55 B minified store. | **S7:** optimistic store key read 0 the moment its only reader gated away while the action was live. | +| S6 — ruled, fix deferred to step 6 | open | A28 at the backing: a `setStore` from imperative mainline is invisible to a derivation created inside a foreign action until the carrying flush; the signal already is. Ruled store-follows-signal. Done at the store twin (first cut of #3521) it cost **+402 B minified** — a node born in the unflushed window has to stage the write, and every arm existed twice (core's, the twin's). Pinned at the store's current value in `posture-store-parity` S6; closes when the store's value selection shares core's `serve`. | The byte cost of fixing a rule at its twin before the twin collapses: the argument for step 6 over more twin fixes. | +| 3b step 6 — `pendingBackingVisible`'s own arms, the store tunnels, `overrideRead` / `latestRead` / lane arms → one `serve` | not started | | Needs the lane ruling (§6). | +| 3c — cargo lifecycle | not started | | Wait for the first new "decided at the pass" fix, or for 3b to settle. | **Method that is working:** the pure-refactor step finds nothing; handing a twin the shared predicate and asking what changed finds the precondition the twin dropped. Every 3b finding so far (S4, S5) was a store site restating a core rule minus one half of it (the replay registration, the transaction entry) — the pattern §1 predicted, in store code the matrix could not see until it had a reader kind for "an untracked read inside a derivation." -**Bytes so far (minified):** step 1 −72 B full bundle; step 3 +31 B core / +123 B store; step 4 +1 B core / +130 B store. The fixes cost bytes; the refactors do not. +**Bytes so far (minified):** step 1 −72 B full bundle; step 3 +31 B core / +123 B store; step 4 +1 B core / +130 B store; step 5 +0 B core / +55 B store (S6 at the twin: +402 B, declined). The fixes cost bytes; the refactors do not — and a fix at a twin costs its rule twice, which is what step 6 exists to stop. ## 1. Why diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 01cd0508c..d99226c2c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -72,7 +72,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:233` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×4 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | | A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×2 effect.ts×1 scheduler.ts×4 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index b52cbbf39..1262b9572 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -122,11 +122,19 @@ export let _hitUnhandledAsync = false; // pending render effect — N async siblings at mount used to produce N copies. let _reportedUnhandledAsync = false; -// Store property nodes that were created solely to carry a pending write (no -// subscribers at write time). Swept after each flush that commits pending -// values — any still without subs get disposed via their `_unobserved` hook, -// releasing the slot in the parent store's node map. +// Store property nodes whose last subscriber left while they carried state +// the backing cannot reconstruct — an optimistic override (overrides live on +// nodes, over a clone the setter discards) or a staged write. Releasing the +// slot then would drop the override: an optimistic store key read `0` the +// moment its only reader gated away while the action was live (S7). Swept +// after each flush — a node still without subs whose override and staging +// have resolved is released through the slot hook; one that regained a +// subscriber leaves the set. const transientStoreNodes = new Set>(); +/** Slot hook's deferral: release this node when its carried state resolves. */ +export function deferSlotRelease(node: Signal): void { + transientStoreNodes.add(node); +} function canUseSimpleSyncFlush(queue: GlobalQueue): boolean { const batch = queue._batch; diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index bc39e3e90..5bd82b208 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -57,6 +57,7 @@ import { import { activeTransition, currentTransition, + deferSlotRelease, globalQueue, insertSubs, type Transition @@ -252,6 +253,11 @@ const slotNodeEquals = function (this: any, a: any, b: any): boolean { setSlotUnobserved((node: any): void => { // A live affects() mark keeps the node addressable (sweep parity). if (node._x?._affectsCount) return; + // An active override or a staged write is state only the node holds (an + // optimistic signal keeps its override whether or not anything reads it — + // store parity, posture-store-parity S7): defer the release to the flush + // that resolves it (the scheduler's transient-node sweep). + if (hasActiveOverride(node) || node._pendingValue !== NOT_PENDING) return deferSlotRelease(node); const t: StoreNextTarget = node._host; const key: PropertyKey = node._key; if (t.n && t.n[key as any] === node) { diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 88c08bdd2..4b5b9df2c 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -25,8 +25,14 @@ * S5 — (fixed, 3b step 4) a mainline derivation's UNTRACKED read of a held * store key is born held (A29) as the signal's is — the store's untracked * paths served the pending value without entering the transaction. - * S6, S7 — DIVERGENCES recorded at their current values, not ruled (see the - * block comment above them). + * S6 — RULED (2026-09-17, "store rules follow signal rules"), fix DEFERRED: + * A28 at the backing. Pinned at the store's CURRENT value below so the + * divergence stays visible; the fix is the store half of `serve` + * (DESIGN-CONSOLIDATION move 3b step 6) — done at the twin it costs + * +400 B minified (a node born in the unflushed window must stage the + * write; #3521 first cut). + * S7 — (fixed) an optimistic override survives its key becoming unobserved + * (the slot release defers to the flush that resolves the override). * Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked` * reader kinds (an untracked read inside a derivation), added with S5. * @@ -54,6 +60,7 @@ import { reconcile, untrack } from "../src/index.js"; +import { $TARGET } from "../src/store/store.js"; const never = () => new Promise(() => {}); @@ -370,22 +377,17 @@ describe("S5 — a mainline derivation's UNTRACKED read of a held value is born } }); -/** DIVERGENCES the 7-reader matrix shows and this file only RECORDS (both - * sides pinned at their current value; not ruled — flip the store or the - * signal when the maintainer rules): - * - * S6 — staged, ambient (a write before any flush), reader created INSIDE a - * foreign action (which adopts the write, spec O1): the signal's memo → - * render effect publishes the committed 0 (A28 / #3510: adopted before - * any flush = unflushed, served committed); the store's publishes the - * pending 1 (pendingBackingVisible: owner context → pending backing). - * The verdict channels already agree (S1); the derivation reads do not. - * S7 — optimistic store, override active, the only reader gated away: the - * signal's x() still reads the override 5 while the action is live - * (A17); the store's s.n reads 0 — the override is invisible to an - * untracked read once no reader observes the key. - */ -describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivation created inside a foreign action", () => { +/** S6 — DIVERGENCE, ruled, fix deferred. Staged, ambient (a write before + * any flush), reader created INSIDE a foreign action (which adopts the + * write, spec O1): the signal's memo → render effect publishes the committed + * 0 (A28 / #3510: adopted before any flush = unflushed, served committed); + * the store's publishes the pending 1 (pendingBackingVisible: owner context + * → pending backing). The verdict channels already agree (S1); the + * derivation reads do not. Ruling: the store follows the signal (0). The + * store side is pinned at its CURRENT value so the divergence stays visible + * until the store's value selection shares core's (`serve`, move 3b step 6); + * flip it to `[0]` then. */ +describe("S6 — DIVERGENCE (ruled: store follows signal; fix deferred to `serve`): staged-ambient write read by a derivation created inside a foreign action", () => { function publishedInsideForeignAction(read: () => number) { const log: number[] = []; action(function* () { @@ -405,7 +407,7 @@ describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivatio setX(1); expect(publishedInsideForeignAction(x)).toEqual([0]); }); - it("store: publishes the pending 1", () => { + it("store: publishes the pending 1 (CURRENT; rule says 0)", () => { const [s, setS] = createStore({ n: 0 }); setS(d => { d.n = 1; @@ -414,7 +416,14 @@ describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivatio }); }); -describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gated away", () => { +/** S7 (fixed): the slot hook released a node the moment its last subscriber + * left — with the override on it (overrides live on nodes, over a clone the + * setter discards), so `s.n` read the committed 0 while the action was live. + * A node carrying an override or a staged write now defers its release to + * the flush that resolves it (deferSlotRelease / sweepTransientStoreNodes), + * as an optimistic signal keeps its override whether or not anything reads + * it. */ +describe("S7 — optimistic override, the only reader gated away: the override is still the value (A17) — signal vs store", () => { function gateAway(read: () => number) { const [show, setShow] = createSignal(true); createRoot(() => { @@ -437,7 +446,7 @@ describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gat flush(); expect(gateAway(x)).toBe(5); }); - it("store: s.n reads the committed 0 once nothing observes the key", () => { + it("store: s.n still reads the override once nothing observes the key", () => { const [s, setS] = createOptimisticStore({ n: 0 }); action(function* () { setS(d => { @@ -446,6 +455,23 @@ describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gat yield never(); })(); flush(); - expect(gateAway(() => s.n)).toBe(0); + expect(gateAway(() => s.n)).toBe(5); + }); + it("store: the node deferred for its override is released once the action settles", async () => { + const [s, setS] = createOptimisticStore({ n: 0 }); + let release!: () => void; + action(function* () { + setS(d => { + d.n = 5; + }); + yield new Promise(res => (release = res)); + })(); + flush(); + expect(gateAway(() => s.n)).toBe(5); + release(); + await settle(); + expect(s.n).toBe(0); + // the slot map no longer holds a node for `n` + expect(((s as any)[$TARGET].n ?? {}).n).toBeUndefined(); }); });