From 6c8607699d498a42d9e518ce3b67a9ca4d4c0b8e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 01:11:22 -0700 Subject: [PATCH 1/2] =?UTF-8?q?refactor(signals):=20shared=20read=20predic?= =?UTF-8?q?ates=20=E2=80=94=20readerSeesCommitted,=20visibleOverride,=20on?= =?UTF-8?q?e=20hasActiveOverride?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-semantic-change step 1 of DESIGN-CONSOLIDATION move 3b (one implementation per rule). read()'s slow tail called the full committed-vs-staged arm inline; it is now readerSeesCommitted(el, c, owner, noCommitted), documented as the rule it enforces, for the store's backing selection and the verdict arms to adopt. hasActiveOverride existed twice (lanes.ts, store.ts) and the composite 'hasActiveOverride && !unflushedOverride' was restated at five sites (latestRead, computePendingState ×2, the store's reader selection, plus the store's own visibleOverride) — one visibleOverride in core now. Gate: signals suite green; visibility oracles and the 738-line posture matrix report are byte-identical to next. Co-authored-by: Claude via Cursor --- .changeset/shared-read-predicates.md | 5 ++ packages/signals/docs/RULES-INDEX.md | 8 +-- packages/signals/src/core/core.ts | 85 ++++++++++++++++++------ packages/signals/src/core/lanes.ts | 11 +-- packages/signals/src/core/verdict.ts | 11 ++- packages/signals/src/store/next/store.ts | 18 ++--- scripts/size/.size-limit.js | 24 ++++++- 7 files changed, 104 insertions(+), 58 deletions(-) create mode 100644 .changeset/shared-read-predicates.md diff --git a/.changeset/shared-read-predicates.md b/.changeset/shared-read-predicates.md new file mode 100644 index 000000000..f79f8b9d7 --- /dev/null +++ b/.changeset/shared-read-predicates.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Consolidate value-selection predicates: `readerSeesCommitted` (the full committed-vs-staged arm read()'s slow tail used to inline) and `visibleOverride` / `hasActiveOverride` (one definition each, previously duplicated between the core, lanes, verdict channels and the store) — a zero-semantic-change refactor toward one implementation per rule (DESIGN-CONSOLIDATION, move 3b). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index eef447a37..d1ccaa862 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -22,7 +22,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | vocabulary | rules | cited in src | cited in tests | cited nowhere | | ---------- | ----- | ------------ | -------------- | ------------- | -| A | 33 | 17 | 33 | 0 | +| A | 33 | 18 | 33 | 0 | | V | 5 | 2 | 5 | 0 | | B | 5 | 0 | 5 | 0 | | C | 4 | 0 | 3 | 1 | @@ -72,11 +72,11 @@ 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` | scheduler.ts×1 | action-await-contract.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×18 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | 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×5 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 … | +| 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` | — | 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 | 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 ce13663d6..254da6ca4 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1626,6 +1626,50 @@ export function enterStagedRead( globalQueue.initTransition(t); } +/** + * Rule 1 (value selection), the full arm: does this reader see a STAGED + * node's COMMITTED value? One implementation of the rule the fast paths + * (readNodeFast, read's fast block) carry as their trivial ternary and that + * every slow site — read's tail, the store's backing selection, the lane and + * verdict arms — used to restate by hand (DESIGN-CONSOLIDATION, move 3b). In order: + * - no reader at all (an untracked read) — the committed frame; + * - a reader under an optimistic lane the engine says reads committed + * (laneReadsCommitted: another lane's hold, #3460); + * - nothing staged; + * - a children-forbidden reader (createTrackedEffect / onSettled: the frame, + * never the graph — A32); + * - a stale reader (render effect) of a FOREIGN transaction's staged write — + * committed, no entanglement (heldFromStale registers the replay; a node + * born held has no committed frame to fall back to, `noCommitted`); + * - A17 for HELD truth (#3164, CONFIG_HELD_TRUTH): staged confirming truth — + * fold-staged onto an armed family, or entangle-stolen by an awaited + * until() — is masked from ordinary readers until its transaction's + * reveal, the retaining transaction's own speculative recomputes included + * (partial override coverage would otherwise compose override + staged + * truth into a state no timeline contains). Authoritative readers + * (until()'s predicate) and latest() see the staged truth — the tunnel that + * keeps the hold deadlock-free. + * False means the reader derives from the staged value and enters its + * transaction (enterStagedRead, A29). + */ +export function readerSeesCommitted( + el: Signal | Computed, + c: Computed | null, + owner: Signal | Computed, + noCommitted: boolean +): boolean { + return !!( + !c || + (currentOptimisticLane !== null && GlobalQueue._laneReadsCommitted!(el, owner, c)) || + el._pendingValue === NOT_PENDING || + c._config & CONFIG_CHILDREN_FORBIDDEN || + (stale && !noCommitted && heldFromStale(el, c)) || + (el._config & CONFIG_HELD_TRUTH && + !latestReadActive && + !(c._config & CONFIG_AUTHORITATIVE_READ)) + ); +} + /** A28 — set when a node is staged (queuePendingNode) or a held node rewritten * (stashHeldRewrite) OUTSIDE a flush; cleared when the next flush begins. The * read sites test this one module boolean instead of `globalQueue._running`: @@ -1680,6 +1724,22 @@ export function unflushedOverride(el: Signal | Computed): boolean { // Companions are optimistic signals written by the engine (see unflushed). return !globalQueue._running && el._x?._overrideTime === clock && !el._x?._parentSource; } +/** Active optimistic override on an armed node (an armed slot idles at + * NOT_PENDING; undefined = unarmed plain node). The writer's own channels — + * the draft, `in`/keys inside the setter — compose on this regardless of + * flush state. */ +export function hasActiveOverride(el: Signal | Computed): boolean { + const x = el._x; + return x !== null && x._overrideValue !== undefined && x._overrideValue !== NOT_PENDING; +} +/** The override a READER sees: installed, and carried by a flush (A28 (5) — + * an optimistic write is a write; until its flush no reader sees it). One + * implementation for read()'s override arm, the verdict channels + * (latestRead, computePendingState) and the store's selection + * (DESIGN-CONSOLIDATION, move 3b). */ +export function visibleOverride(el: Signal | Computed): boolean { + return hasActiveOverride(el) && !unflushedOverride(el); +} /** A derivation served the committed value because of an unflushed write * (A28) must run again in the flush that carries it — the late-linker case * (#3337's reason to defer the walk): it linked after the write walked. */ @@ -1928,7 +1988,7 @@ export function read(el: Signal | Computed): T { nodeName: (owner as any)?._name }); - if (el._x?._overrideValue !== undefined && el._x?._overrideValue !== NOT_PENDING) { + if (hasActiveOverride(el)) { // A17: the override IS the value for every reader — except an authoritative // reader (until()'s predicate carries CONFIG_AUTHORITATIVE_READ): it must // observe independently-arriving truth, and serving it the caller's own @@ -1992,26 +2052,9 @@ export function read(el: Signal | Computed): T { if (pendingCheckActive) GlobalQueue._recordFresh!(el, u); return u as T; } - const value = - !c || - (currentOptimisticLane !== null && - GlobalQueue._laneReadsCommitted!(el, owner, c as Computed)) || - el._pendingValue === NOT_PENDING || - c._config & CONFIG_CHILDREN_FORBIDDEN || - (stale && !noCommitted && heldFromStale(el, c as Computed)) || - // A17 for HELD truth (#3164, see CONFIG_HELD_TRUTH): staged confirming - // truth — fold-staged onto an armed family, or entangle-stolen by an - // awaited until() — is masked from ordinary readers until its - // transaction's reveal; the retaining transaction's own speculative - // recomputes included (partial override coverage would otherwise - // compose override + staged truth into a state no timeline contains). - // Authoritative readers (until()'s predicate) and latest() see the - // staged truth — the tunnel that keeps the hold deadlock-free. - (el._config & CONFIG_HELD_TRUTH && - !latestReadActive && - !((c as Computed)._config & CONFIG_AUTHORITATIVE_READ)) - ? el._value - : (enterStagedRead(el), el._pendingValue as T); + const value = readerSeesCommitted(el, c as Computed | null, owner, noCommitted) + ? el._value + : (enterStagedRead(el), el._pendingValue as T); // Record that this isPending() probe observed the fresh pending value, so // the probe doesn't pair "pending" with the new value (#2831). if (pendingCheckActive) GlobalQueue._recordFresh!(el, value); diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index 3dd12e3c3..75a723321 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -4,7 +4,8 @@ import { NOT_PENDING, REACTIVE_DISPOSED } from "./constants.js"; -import { currentOptimisticLane, ext } from "./core.js"; +import { currentOptimisticLane, ext, hasActiveOverride } from "./core.js"; +export { hasActiveOverride }; import { enqueueSub } from "./heap.js"; import { activeTransition, @@ -201,14 +202,6 @@ export function resolveTransition(el: Signal | Computed): Transition | return resolveLane(el)?._transition ?? el._transition; } -/** - * Check if a node has an active optimistic override. - */ -export function hasActiveOverride(el: Signal | Computed): boolean { - const x = el._x; - return x !== null && x._overrideValue !== undefined && x._overrideValue !== NOT_PENDING; -} - /** * Assign or merge a lane onto a node. At convergence points (node already has * a different active lane), merge unless the node has an active override. diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index ace5b0ef5..62e39d2fe 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -37,7 +37,7 @@ import { setSignal, unflushed, unflushedCompanions, - unflushedOverride, + visibleOverride, unflushedValue, setStrictRead, stale, @@ -292,8 +292,7 @@ function computePendingState(el: Signal | Computed): boolean { if ( el._config & CONFIG_OVERRIDE_SUPERSEDED && el._pendingValue === NOT_PENDING && - hasActiveOverride(el) && - !unflushedOverride(el) + visibleOverride(el) ) return !el._equals || !el._equals(el._value as any, unwrapOverride(el._x?._overrideValue)); // A28 (2): an unflushed write is not yet observable — the verdict answers @@ -306,7 +305,7 @@ function computePendingState(el: Signal | Computed): boolean { // non-final"; an override is one (a node whose first landing was held // by a reveal it never got to commit, then superseded under its // override, read false here). - if (hasActiveOverride(el) && !unflushedOverride(el)) + if (visibleOverride(el)) return !el._equals || !el._equals(staged as any, unwrapOverride(el._x?._overrideValue)); // A quiet re-ask's held landing still answers the same question: the // classification survives the landing (asyncWrite) and dies with the @@ -503,9 +502,7 @@ function latestRead(el: Signal | Computed): T { const prevPending = latestReadActive; setLatestReadActive(false); const visibleValue = ( - hasActiveOverride(el) && !unflushedOverride(el) - ? unwrapOverride(el._x?._overrideValue) - : el._value + visibleOverride(el) ? unwrapOverride(el._x?._overrideValue) : el._value ) as T; // A28: an unflushed write is not the staged value latest() serves. The // shadow was written at the source's write to mirror it (A8) — consult it diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index af58d9d9b..90a663159 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -36,7 +36,8 @@ import { isEqual, latestReadActive, stale, - unflushedOverride, + hasActiveOverride, + visibleOverride, prepareComputed, read as readNode, READ_SLOW, @@ -1752,18 +1753,7 @@ export function runAuthoritative(fn: () => T): T { } } -/** Active optimistic override on an armed node (armed slot idles at - * NOT_PENDING; undefined = unarmed plain node). */ -export function hasActiveOverride(node: Signal): boolean { - return node._x?._overrideValue !== undefined && node._x?._overrideValue !== NOT_PENDING; -} -/** The override a READER sees: installed, and carried by a flush (A28 (5) — - * an optimistic write is a write; until its flush no reader sees it). The - * writer's own channels (the draft, `in`/keys inside the setter) compose on - * the installed override regardless — they use hasActiveOverride. */ -export function visibleOverride(node: Signal): boolean { - return hasActiveOverride(node) && !unflushedOverride(node); -} +export { hasActiveOverride, visibleOverride }; /** The reading computation is until()'s authoritative-view predicate — same * source of truth as core read()'s A17 carve-out (`context`, which persists @@ -1801,7 +1791,7 @@ function nodeValue(node: Signal, backing: any): any { // only: staged pending values are authoritative, overrides are the // caller's optimism. const v = - !authoritativeServe() && hasActiveOverride(node) && !unflushedOverride(node) + !authoritativeServe() && visibleOverride(node) ? unwrapOverride(node._x?._overrideValue) : node._pendingValue !== NOT_PENDING && (latestReadActive || diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index f465ec285..de534e50c 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -536,7 +536,13 @@ module.exports = [ // the O5 fix, measured at 16,493 B against `next`'s 16,481 (+12 brotli for // `_companionChildren?.delete(n)` in unlinkFirewallChild). The 16.50 KB cap // is unchanged; core floor and isPending/latest scenarios are unchanged. - limit: "16.50 KB", + // Shared read predicates (DESIGN-CONSOLIDATION move 3b step 1, 2026-09-17): + // readerSeesCommitted / visibleOverride / one hasActiveOverride. Minified + // signals: core +13 B, +createStore -38 B, full bundle -72 B; brotli on the + // 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", modifyEsbuildConfig }, { @@ -913,7 +919,13 @@ module.exports = [ // the core walks it on solid-js's server owners (`ownerPath`, // `OBSERVE.exclude`). ~+40 B across the prod scenarios; the observe ones // moved by gzip noise or shrank. - limit: "19.85 KB", + // Shared read predicates (DESIGN-CONSOLIDATION move 3b step 1, 2026-09-17): + // readerSeesCommitted / visibleOverride / one hasActiveOverride. Minified + // signals: core +13 B, +createStore -38 B, full bundle -72 B; brotli on the + // 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", modifyEsbuildConfig }, { @@ -1210,7 +1222,13 @@ module.exports = [ // 15,170 B before the #3496 rebase (+30 over its base); 0 B minified in // the signals floor (24,578 flat). Combined with `_parent` mangling: // 15,250 B; cap ratcheted to the measured output. - limit: "15.25 KB", + // Shared read predicates (DESIGN-CONSOLIDATION move 3b step 1, 2026-09-17): + // readerSeesCommitted / visibleOverride / one hasActiveOverride. Minified + // signals: core +13 B, +createStore -38 B, full bundle -72 B; brotli on the + // pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle + // measured at 15,271 B against `next`'s 15,243 — compressor layout, the + // deltas across the ten scenarios run -19…+45 B in both directions. + limit: "15.30 KB", modifyEsbuildConfig }, { From cfd4dc03a8131d0e4c46ac807fc8fe588268ac24 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 01:42:30 -0700 Subject: [PATCH 2/2] refactor(signals): store node reads select by readerSeesCommitted (Rule 1, one implementation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of DESIGN-CONSOLIDATION move 3b. nodeValue — the store's untracked node-read selection — restated core read()'s committed-vs-staged arm by hand (inOwnerContext, a stale-of-foreign 'foreignHold' twin, the HELD-truth mask). It now calls readerSeesCommitted with core's context selection (readerContext: a root reads as its parent computed) behind the two store-only tunnels, latest() (#3075) and authoritativeServe (truth authors). Not zero-change — the twin was missing a precondition of the rule it copied: a stale reader (render effect) whose untracked read is of a store key held by a foreign action was served committed (correct, A15/A26) but never recorded for replay at the commit (heldFromStale's _gatedSubs contract, which the signal path performs), so the effect stayed on the pre-action value after the action settled. Pinned as S4 in posture-store-parity (signal vs store, both now [0, 0, 1]). The backing twin (no node: pendingBackingVisible / heldFromReader) has the same gap and no node to record the reader on — pinned it.fails as the step-3 item. The lane arm (laneReadsCommitted) now also applies to untracked store node reads as it does to tracked ones; no matrix cell or test distinguishes. Gate: suite green (+2 pins, 1 expected fail); visibility oracles and the 621-cell posture matrix byte-identical to next; minified store −5 B, full bundle brotli −44 B vs step 1. Co-authored-by: Claude via Cursor --- .changeset/store-node-rule1.md | 5 + packages/signals/docs/RULES-INDEX.md | 70 +++++------ packages/signals/src/store/next/store.ts | 40 ++++--- .../tests/posture-store-parity.test.ts | 111 +++++++++++++++++- 4 files changed, 172 insertions(+), 54 deletions(-) create mode 100644 .changeset/store-node-rule1.md diff --git a/.changeset/store-node-rule1.md b/.changeset/store-node-rule1.md new file mode 100644 index 000000000..5548e411c --- /dev/null +++ b/.changeset/store-node-rule1.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Store node reads select committed-vs-staged by the core's `readerSeesCommitted` (one Rule 1 implementation for signals and store nodes). Fixes a render effect's untracked read of a store key held by a foreign action never replaying at that action's commit — the store's hand-restated stale-of-foreign clause served the committed value but skipped the replay registration the signal path performs, leaving the effect on the pre-action value permanently. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d1ccaa862..1f3e87e83 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -43,41 +43,41 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## A — spec propositions -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:259` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:267` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:275` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:291` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | -| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | -| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | -| 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 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×3 | 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… | -| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×11 visibility-oracle.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:340` | invariants.ts×1 | 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… | -| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:347` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | -| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | -| 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 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… | -| 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… | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| --- | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:259` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:267` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:275` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:291` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| 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 … | +| 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… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×11 visibility-oracle.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:340` | invariants.ts×1 | 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… | +| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:347` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | +| 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… | +| 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… | +| 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/store/next/store.ts b/packages/signals/src/store/next/store.ts index 90a663159..eb9221898 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -38,6 +38,7 @@ import { stale, hasActiveOverride, visibleOverride, + readerSeesCommitted, prepareComputed, read as readNode, READ_SLOW, @@ -66,7 +67,7 @@ import { setProjectionWriteActive, setStoreCommitHook } from "../../core/scheduler.js"; -import type { Owner, Signal } from "../../core/types.js"; +import type { Computed, Owner, Signal } from "../../core/types.js"; import { pendingCheckActive, strictRead } from "../../core/core.js"; import { DEV, @@ -1608,11 +1609,17 @@ const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor * computed (#2687 — untracked reads inside mapArray Roots see in-flight * values mid-flush). CHILDREN_FORBIDDEN execution scopes (createTrackedEffect * / onSettled callbacks) get COMMITTED visibility (#3006), same as core. */ -function inOwnerContext(): boolean { +/** Core read()'s reader: the current computation, a root reading as its + * parent computed (`context` persists under untrack — an untracked read + * inside an effect is still that effect's read). */ +function readerContext(): Computed | null { const c: any = getOwner(); - if (c === null) return false; - const eff = c._root ? c._parentComputed : c; - return eff != null && !(eff._config & CONFIG_CHILDREN_FORBIDDEN); + return c === null ? null : c._root ? (c._parentComputed ?? null) : c; +} + +function inOwnerContext(): boolean { + const eff = readerContext(); + return eff !== null && !(eff._config & CONFIG_CHILDREN_FORBIDDEN); } /** CHILDREN_FORBIDDEN execution scope (createTrackedEffect / onSettled @@ -1794,20 +1801,17 @@ function nodeValue(node: Signal, backing: any): any { !authoritativeServe() && visibleOverride(node) ? unwrapOverride(node._x?._overrideValue) : node._pendingValue !== NOT_PENDING && + // Store-only tunnels first: latest() reaches this untracked path for + // store keys (#3075) and truth authors (authoritativeServe: the + // projection derive's draft, write-override) see staged truth + // unconditionally. Then Rule 1's committed-vs-staged arm — the + // same readerSeesCommitted core read() serves tracked reads by + // (owner context, children-forbidden, stale-of-foreign, HELD truth, + // lanes) — with core's context selection (a root reads as its + // parent computed). (latestReadActive || - // Owner-context pending visibility — except HELD truth (#3164, - // see CONFIG_HELD_TRUTH: fold-staged or entangle-stolen - // confirming truth), which only authoritative/latest readers - // see (core read()'s A17-for-held-truth twin; ordinary readers - // keep committed until the transaction's reveal — latest() is - // exempted by the leading arm above). - // — and core read()'s stale-reader clause: a render effect's - // untracked read of a FOREIGN transaction's write sees committed - // (#3336; the tracked read reaches core read() and already does). - (((inOwnerContext() && - !(stale && node._transition !== null && foreignHold(node._transition))) || - authoritativeServe()) && - !(node._config & CONFIG_HELD_TRUTH && !authoritativeServe()))) + authoritativeServe() || + !readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false)) ? node._pendingValue : backing; return v === (FORCE as any) ? backing : v; diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 7c488dbb2..98edb5172 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -37,7 +37,8 @@ import { createStore, flush, isPending, - latest + latest, + untrack } from "../src/index.js"; const never = () => new Promise(() => {}); @@ -121,3 +122,111 @@ describe("S2 — creation in boundary content over a held value publishes it (OB expect(s.n).toBe(0); }); }); + +/** A render effect (stale reader) whose UNTRACKED read is of a value held by + * a foreign action: it is served committed (A15 / A26) and — because the + * commit will change what it read — is recorded for replay at that commit + * (core heldFromStale, the `_gatedSubs` contract). The signal side always + * did this; the store's node path restated the stale-of-foreign clause + * without the registration (nodeValue's `foreignHold` twin), so the effect + * showed the committed value after the action settled, permanently. Both + * paths now go through one readerSeesCommitted. */ +function untrackedStaleReplay(read: () => number, hold: () => void, release: () => void) { + const [u, setU] = createSignal(0); + const log: number[] = []; + createRoot(() => { + createRenderEffect( + () => { + u(); + return untrack(read); + }, + v => { + log.push(v); + } + ); + }); + flush(); + hold(); + flush(); + setU(1); // the stale reader re-runs off the hold: committed + flush(); + const held = [...log]; + release(); + return { held, log }; +} +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +describe("S4 — a stale reader's untracked read of a foreign hold replays at the hold's commit (A15 / A26 replay contract) — signal vs store", () => { + it("signal: committed while held, the new value after the action settles", async () => { + const [x, setX] = createSignal(0); + let release!: () => void; + const r = untrackedStaleReplay( + x, + () => + action(function* () { + setX(1); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); + it("store leaf with a node: committed while held, the new value after the action settles", async () => { + const [s, setS] = createStore({ n: 0 }); + // A tracked reader materializes the node for `n`; the untracked read + // then serves through it (nodeValue). + createRoot(() => + createRenderEffect( + () => s.n, + () => {} + ) + ); + flush(); + let release!: () => void; + const r = untrackedStaleReplay( + () => s.n, + () => + action(function* () { + setS(d => { + d.n = 1; + }); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + 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 () => { + const [s, setS] = createStore({ n: 0 }); + let release!: () => void; + const r = untrackedStaleReplay( + () => s.n, + () => + action(function* () { + setS(d => { + d.n = 1; + }); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); +});