diff --git a/.changeset/store-hold-visible.md b/.changeset/store-hold-visible.md new file mode 100644 index 000000000..a3bfb0e91 --- /dev/null +++ b/.changeset/store-hold-visible.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +The store's backing-level visibility (which container — committed or staged — a reader of a held store sees, for property reads, `in`, `Object.keys` and descriptors) is one `holdVisible` on the core's shared predicates for both hold kinds (a setter's fold, an adoption under a transaction), replacing six store-local helpers. No behavior change; −313 B minified in the store. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 751336182..72fb32be7 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,9 +59,9 @@ 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×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 … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×5 lanes.ts×2 scheduler.ts×2 store.ts×1 | 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×10 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 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×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… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×4 constants.ts×2 core.ts×10 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×1 | optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×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×7 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 posture-store-parity.test.ts×5 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×2 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… | @@ -70,7 +70,7 @@ 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` | 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… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 store.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×20 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×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×7 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 … | diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index d8855fc91..22c7228bb 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -403,52 +403,26 @@ function heldFoldTransition(target: StoreNextTarget): Transition | null { } /** - * Core read()'s committed-visibility clause, at the backing (#3336): - * `(stale && el._transition !== null) ? _value : _pendingValue`. While a live - * transaction holds the pending backing, a stale (render) reader — and a - * reader with no owner at all — sees committed, through every channel: an - * untracked read in the effect, `in`, `Object.keys`, `deep()`/`snapshot()`. - * The node path already answers this way; without the backing twin the same - * effect read `0` through `a.count` and `1` through `untrack(() => a.count)` - * or `"added" in a`. Speculation stays visible to non-stale owner-context - * readers and to the peek from inside one; a pending backing with no - * transaction (a same-tick plain write) is unaffected. + * Rule 1 at the backing, the hold half (core serve()'s stale-of-foreign and + * A29 arms, for a container instead of a node): given the transaction + * holding what a reader `c` (a pass; callers serve context-free readers the + * committed container themselves) is about to be served, is the STAGED + * container its to see? Its own hold, or none — yes. A foreign hold — a + * stale pass (render effect) keeps the committed frame and is recorded for + * replay at the hold's commit (recordStaleReplay, A15 / A26); a deriving + * pass takes the staged world and enters the transaction (enterStagedRead, + * A29). Shared by both hold kinds: a setter's fold (committed `v`, staged + * `pb`, the write-time stamp) and an adoption under a transaction + * (committed = the held view `hv`, staged = the adopted `v`, #3074). */ -function heldFromReader(target: StoreNextTarget): boolean { - if (!stale && inOwnerContext()) return false; - const txn = liveFoldTransition(target); - 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); -} - -/** The other half of serving a held backing: a deriving reader (a pass in - * owner context) served the pending backing a live transaction holds - * derives from that transaction's world and enters it (A29, core - * enterStagedRead on the same arm) — its result is held with the fold, not - * published into the mainline frame. Without it a mainline memo's untracked - * read of a held key (`untrack(() => s.n)`, `deep(s)`) published the - * unrevealed value while the same read of a signal was born held. */ -function enterHeldBacking(target: StoreNextTarget, txn = liveFoldTransition(target)): void { - if (txn !== null && readerContext() !== null) enterStagedRead(null, txn); -} - -/** Core read()'s `activeTransition !== el._transition`: a hold belongs to a - * FOREIGN transaction unless the flush running now is that transaction's — - * its own stale readers (a render effect recomputing in it, whose run the - * commit applies) see the staged world. */ -function foreignHold(txn: Transition): boolean { - return !ownsHold(txn); +function holdVisible(txn: Transition | null, c: Computed): boolean { + if (txn === null || ownsHold(txn)) return true; + if (stale) { + recordStaleReplay(txn, c); + return false; + } + enterStagedRead(null, txn); + return true; } function stageHeldKey(node: Signal, nv: any, txn: Transition): void { @@ -1652,23 +1626,6 @@ function readerContext(): Computed | null { 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 - * callbacks). Distinct from context-free: these scopes get committed - * visibility even against a projection's authoritative-elect pending - * backing (#3082) — parity with signals, where core read() serves - * committed to them regardless of staged writes. */ -function inForbiddenScope(): boolean { - const c: any = getOwner(); - if (c === null) return false; - const eff = c._root ? c._parentComputed : c; - return eff != null && !!(eff._config & CONFIG_CHILDREN_FORBIDDEN); -} - /** A pending fold is transition-held when any written node's parked value is * stamped by a live transition (a plain batch parking — the lazy-recompute * read case — has no transition stamp and serves fresh). */ @@ -1688,32 +1645,23 @@ function foldHeld(target: StoreNextTarget): boolean { } function readSource(target: StoreNextTarget): Record { - // Held view first (#3074): an adoption staged under a live hold serves the - // pre-hold committed backing to committed-visibility readers. Speculative - // readers — drafts, write-override, owner-context computeds recomputing - // inside the transaction, and latest() reads — see the adopted backing. - if ( - target.ht !== null && - !latestReadActive && - !inDraft(target) && - !getWriteOverride() && - // A stale (render) reader of a FOREIGN transaction's hold is a - // committed-visibility reader whatever its owner context (#3336). - (!inOwnerContext() || - (stale && target.ht !== PLAIN_HOLD && foreignHold(currentTransition(target.ht)))) - ) { + // Adoption hold first (#3074): an adoption staged under a live transaction + // (or a latest()-pull, PLAIN_HOLD) serves the pre-hold committed view to + // committed-visibility readers — context-free and children-forbidden ones, + // and stale passes off a foreign hold. Drafts, write-override and latest() + // see the adopted backing. + const ht = target.ht; + if (ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) { const hv = heldMaskView(target); 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; + const c = readerContext(); + if ( + c === null || + c._config & CONFIG_CHILDREN_FORBIDDEN || + !holdVisible(ht === PLAIN_HOLD ? null : currentTransition(ht as Transition), c) + ) + return hv; } - } else if (target.ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) { - // An owner-context deriving reader served the ADOPTED view under a live - // adoption hold derives from the adoption's transaction (A29). - enterHeldBacking(target, heldAdoptionTransition(target)); } return pendingBackingVisible(target, false) ? target.pb! : target.v; } @@ -1734,35 +1682,28 @@ function readSource(target: StoreNextTarget): Record { * divergence from context-free per-key reads) — but never through a hold: * held truth stays masked exactly as it is for per-key readers. */ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): boolean { - return ( - target.pb !== null && - (inDraft(target) || - getWriteOverride() || - // Owner-context (and speculative-peek) readers see the pending - // backing — EXCEPT held truth on an optimistic family (#3164 fold): - // a live pb on an opt family outside the draft/write-override windows - // is a staged landing (tentative drafts never outlive their setter), - // and only the authoritative postures and latest() see it (the - // backing-level twin of core read()'s A17-for-held-truth arm; - // ordinary readers keep committed until the transaction's reveal). - // Stale readers and owner-less peeks of a TRANSACTION-held backing see - // committed, as core read() serves them (#3336, heldFromReader). - ((speculative || inOwnerContext()) && - !heldTruthMasked(target) && - !heldFromReader(target) && - (enterHeldBacking(target), true)) || - // A projection's pending backing is authoritative-elect: serve it to - // context-free readers too UNLESS a transition is holding the node - // commits (downstream async hold — stale committed is the contract) - // or the reader is a CHILDREN_FORBIDDEN scope, which never observes - // its own unsettled write (#3082, signal parity per #3006). - // (The write-time stamp covers keys with no node, #3336.) - (target.fam !== null && - !heldTruthMasked(target) && - !foldHeld(target) && - liveFoldTransition(target) === null && - !inForbiddenScope())) - ); + if (target.pb === null) return false; + // The writer's own channels compose on the pending backing regardless. + if (inDraft(target) || getWriteOverride()) return true; + // HELD truth on an optimistic family (#3164 fold) is masked from ordinary + // readers until the transaction's reveal (the backing-level twin of core + // serve()'s CONFIG_HELD_TRUTH arm; authoritative postures and latest() + // tunnel through inside heldTruthMasked). + if (heldTruthMasked(target)) return false; + const c = readerContext(); + if (c === null || c._config & CONFIG_CHILDREN_FORBIDDEN) { + // No pass, or a children-forbidden one: the committed frame (A32) — + // except the speculative peek (deep()/snapshot()), which sees ordinary + // pending staging but never through a live foreign hold, and a + // projection's pending backing, authoritative-elect for context-free + // readers UNLESS a transition holds the node commits (downstream async + // hold — stale committed is the contract; the write-time stamp covers + // keys with no node, #3336) or the scope is children-forbidden (#3082). + const txn = liveFoldTransition(target); + if (speculative) return txn === null || ownsHold(txn); + return target.fam !== null && c === null && !foldHeld(target) && txn === null; + } + return holdVisible(liveFoldTransition(target), c); } /** #3164 fold: HELD truth on an optimistic family — a pending backing