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/.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 eef447a37..1f3e87e83 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 | @@ -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×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 … | -| 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… | -| 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/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..eb9221898 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -36,7 +36,9 @@ import { isEqual, latestReadActive, stale, - unflushedOverride, + hasActiveOverride, + visibleOverride, + readerSeesCommitted, prepareComputed, read as readNode, READ_SLOW, @@ -65,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, @@ -1607,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 @@ -1752,18 +1760,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,23 +1798,20 @@ 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 && + // 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]); + }); +}); 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 }, {