diff --git a/.changeset/store-backing-stale-replay.md b/.changeset/store-backing-stale-replay.md new file mode 100644 index 000000000..99bb66a66 --- /dev/null +++ b/.changeset/store-backing-stale-replay.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A render effect's untracked read of a store key held by a foreign action — a key with no node, or a `reconcile` adoption held by the action — is now recorded for replay at the action's commit, as the signal path always was. Previously the store's backing-level selection served the committed value but skipped the registration, so the effect stayed on the pre-action value after the action settled. One registration (`recordStaleReplay`) is shared by the node path and the store's backing paths. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 1f3e87e83..d9735bdd1 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [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:199` | async.ts×3 core.ts×4 lanes.ts×2 scheduler.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.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 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.states.ts×5 visibility-oracle.states.ts×6 visibility-oracle.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 … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×5 lanes.ts×2 scheduler.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.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 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.states.ts×5 visibility-oracle.states.ts×6 visibility-oracle.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:173` | verdict.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:31` | async.ts×4 constants.ts×2 core.ts×9 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×2 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×11 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×25 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:41` | async.ts×3 constants.ts×1 core.ts×6 optimistic.ts×6 scheduler.ts×3 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 lane-outside-view.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.states.ts×9 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×24 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… | @@ -70,13 +70,13 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | | A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×3 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | | 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` | 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… | +| 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×3 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 | 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×1 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 … | -| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | core.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:221` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | ## V — fixed violations diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 254da6ca4..c940dcd6d 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1557,12 +1557,22 @@ export function installAuthoritativeRead(): void { * also pending on an upstream re-ask blocks through that flight until it * lands, and its landing re-runs the reader into the normal path. */ +/** The replay half of the stale-of-foreign clause (A15 / A26): a stale reader + * served the committed value because `txn` holds what it read re-runs at + * txn's commit, when the value it was denied becomes the frame — unless its + * own last value already came from that transaction. One registration for + * the node path (heldFromStale) and the store's backing paths, which have + * no node to carry the hold (heldFromReader, the adoption hold view). */ +export function recordStaleReplay(txn: Transition, c: Computed): void { + const vt: Transition | null | undefined = (c as any)._valueTransition; + if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); +} + function heldFromStale(el: Signal | Computed, c: Computed): boolean { const t = el._transition; if (t === null || t === activeTransition) return false; const txn = currentTransition(t); - const vt: Transition | null | undefined = (c as any)._valueTransition; - if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); + recordStaleReplay(txn, c); const reporters = txn._asyncReporters.get(el as Computed); if (reporters) reporters.add(c); else if ((el as Computed)._statusFlags & STATUS_PENDING) diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index eb9221898..68118d3bd 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -39,6 +39,7 @@ import { hasActiveOverride, visibleOverride, readerSeesCommitted, + recordStaleReplay, prepareComputed, read as readNode, READ_SLOW, @@ -408,7 +409,19 @@ function heldFoldTransition(target: StoreNextTarget): Transition | null { function heldFromReader(target: StoreNextTarget): boolean { if (!stale && inOwnerContext()) return false; const txn = liveFoldTransition(target); - return txn !== null && foreignHold(txn); + return txn !== null && foreignHold(txn) && (staleReplay(txn), true); +} + +/** The replay half of the clause (core recordStaleReplay): the stale reader + * just denied the held value re-runs at the hold's commit. Without it the + * node path replayed (heldFromStale) and the backing paths did not — an + * effect's untracked read of a key with no node stayed on the pre-action + * value after the action settled (posture-store-parity S4). Only a reader in + * context has a pass to replay; a children-forbidden reader sees the frame + * and never the graph (A32). */ +function staleReplay(txn: Transition): void { + const c = readerContext(); + if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c); } /** Core read()'s `activeTransition !== el._transition`: a hold belongs to a @@ -1668,7 +1681,13 @@ function readSource(target: StoreNextTarget): Record { (stale && target.ht !== PLAIN_HOLD && foreignHold(currentTransition(target.ht)))) ) { const hv = heldMaskView(target); - if (hv !== null) return hv; + if (hv !== null) { + // The reader denied the adopted view replays at the adoption's commit + // (the replay half of the clause; a latest()-pull PLAIN_HOLD is not a + // transaction and has no commit). + if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition)); + return hv; + } } return pendingBackingVisible(target, false) ? target.pb! : target.v; } diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 98edb5172..bb5bf920b 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -38,6 +38,7 @@ import { flush, isPending, latest, + reconcile, untrack } from "../src/index.js"; @@ -208,10 +209,9 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th expect(r.log).toEqual([0, 0, 1]); }); // The backing twin (pendingBackingVisible / heldFromReader) serves the key - // with no node the same committed value but has no node to record the - // reader on — the effect never replays. Rule 1's backing-level form is an - // open item of DESIGN-CONSOLIDATION move 3b (step 3). - it.fails("store leaf WITHOUT a node: the same replay (backing twin gap)", async () => { + // with no node the same committed value; it records the reader on the + // holding transaction directly (staleReplay → core recordStaleReplay). + it("store leaf WITHOUT a node: the same replay (backing twin)", async () => { const [s, setS] = createStore({ n: 0 }); let release!: () => void; const r = untrackedStaleReplay( @@ -229,4 +229,34 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th await settle(); expect(r.log).toEqual([0, 0, 1]); }); + // The other hold kind: an adoption (reconcile inside an action) holds at + // the backing (`ht`, #3074) — the held view is served to the stale reader + // by readSource, and the same replay is recorded there. + for (const withNode of [false, true]) { + it(`store reconcile held by a live action, ${withNode ? "with" : "without"} a node: the same replay`, async () => { + const [s, setS] = createStore({ n: 0 }); + if (withNode) { + createRoot(() => + createRenderEffect( + () => s.n, + () => {} + ) + ); + flush(); + } + let release!: () => void; + const r = untrackedStaleReplay( + () => s.n, + () => + action(function* () { + setS(reconcile({ n: 1 })); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); + } }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index de534e50c..9474b909e 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -542,7 +542,13 @@ module.exports = [ // pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle // measured at 16,509 B against `next`'s 16,495 — compressor layout, the // deltas across the ten scenarios run -19…+45 B in both directions. - limit: "16.55 KB", + // Stale-reader replay at the backing (move 3b step 3, 2026-09-17): a + // render effect's untracked read of a store key held by a foreign action + // — no node, or an adoption hold — is recorded for replay at the commit + // as the signal path always was (core recordStaleReplay, shared with + // heldFromStale). +31 B minified core, +123 B minified store (the fix, + // posture-store-parity S4); measured at 16,580 B. + limit: "16.60 KB", modifyEsbuildConfig }, { @@ -925,7 +931,13 @@ module.exports = [ // pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle // measured at 19,894 B against `next`'s 19,849 — compressor layout, the // deltas across the ten scenarios run -19…+45 B in both directions. - limit: "19.90 KB", + // Stale-reader replay at the backing (move 3b step 3, 2026-09-17): a + // render effect's untracked read of a store key held by a foreign action + // — no node, or an adoption hold — is recorded for replay at the commit + // as the signal path always was (core recordStaleReplay, shared with + // heldFromStale). +31 B minified core, +123 B minified store (the fix, + // posture-store-parity S4); measured at 19,902 B. + limit: "19.95 KB", modifyEsbuildConfig }, { @@ -1112,7 +1124,13 @@ module.exports = [ // A projection's leaf companions die with it; latest() of a dead leaf creates // none (spec O5, 2026-09-16): 29,953 B (+43 over the cap); +104 B minified in // owner.ts (core floor), the shadow retirement lives in verdict.ts. - limit: "30.00 KB", + // Stale-reader replay at the backing (move 3b step 3, 2026-09-17): a + // render effect's untracked read of a store key held by a foreign action + // — no node, or an adoption hold — is recorded for replay at the commit + // as the signal path always was (core recordStaleReplay, shared with + // heldFromStale). +31 B minified core, +123 B minified store (the fix, + // posture-store-parity S4); measured at 30,034 B. + limit: "30.05 KB", modifyEsbuildConfig }, {