diff --git a/.changeset/born-held.md b/.changeset/born-held.md new file mode 100644 index 000000000..1eebe4b09 --- /dev/null +++ b/.changeset/born-held.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A memo or effect created from mainline code while a transaction holds a value it reads is now "born held" (A29, creation-time form): its creation pass derives from the transaction's staged world and is staged into that transaction — committed with it, and for an effect first run by its commit — instead of committing the held value into the mainline frame beside readers that show the committed one. The mainline block that created it is untouched: `enterStagedRead` no longer enters the transaction ambiently from creation code, so an unrelated write made after such a mount (a click that opens a panel while an action is in flight, then writes something else) is a mainline write again rather than being swallowed into the action. An untracked read of a born-held memo throws `NotReadyError` until the commit — it has no committed value to serve. diff --git a/.changeset/ispending-before-first-commit.md b/.changeset/ispending-before-first-commit.md new file mode 100644 index 000000000..a157f9170 --- /dev/null +++ b/.changeset/ispending-before-first-commit.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`isPending()` on an optimistic node whose own source landed a value differing from the displayed override now reads `true` even when the node has never committed (its first landing was held by a reveal that never landed). The verdict's uninitialized suppression — A19 exception (1), "no observable value exists" — no longer applies under a displayed override, which is an observable value (A18 d). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 542edcb6b..86fee5347 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -62,8 +62,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×3 core.ts×3 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 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 treeshake.test.ts×4 visibility-oracle.test.ts×7 | [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:165` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×3 | [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×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 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.test.ts×16 | [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×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 visibility-oracle.test.ts×12 | [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:93` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×10 | [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… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×1 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle.test.ts×13 | [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:93` | 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.test.ts×11 | [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:281` | 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:288` | — | 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:173` | — | spec-async-semantics.test.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… | @@ -72,9 +72,9 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | — | uninitialized-visibility.test.ts×3 | [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:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle.test.ts×2 | [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:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×10 | [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… | -| A29 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 | held-conditional-memo.test.ts×1 visibility-oracle.test.ts×3 | [ruled 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 live transaction h… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×4 effect.ts×1 optimistic.ts×1 | born-held.test.ts×3 held-conditional-memo.test.ts×1 treeshake.test.ts×1 visibility-oracle.test.ts×5 | [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:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.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:75` | core.ts×1 | 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 … | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | 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:83` | — | visibility-oracle.test.ts×7 | [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… | ## V — fixed violations diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 84e4d2cdc..c449c426b 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -44,7 +44,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t **Pinned by:** `tests/spec-async-semantics.test.ts` (same pins — behavior coincides in unmerged graphs); `tests/optimistic-store-layer-scope.test.ts` (store corollary: disjoint-key independence, nested rows, same-key entanglement, delete survival, ambient flush-end); `tests/optimistic-lane-transaction-ownership.test.ts` (node corollary: shared-subscriber lane merge with swapped write order, three-action signal hijack); `tests/optimistic-lane-release.test.ts` (body-end corollary, and #3426: the last async reader unmounting releases the frame) **Mechanism (index, 2026-09-14):** `CONFIG_OVERRIDE_SUPERSEDED` set by `supersedeOverride` on a differing, postdating source landing; `supersededRead` serves the arrived value to tracked readers while untracked reads keep `_overrideValue` until commit (#3331); `_overrideTime` / `_overrideStamp` decide "postdates" and provenance (2026-09-10); `_overrideOwner` answers ownership instead of the lane (#2912); the arrival itself is staged in `_pendingValue` and elevated by `commitPendingNode` like any transition write; `endOptimism` (`GlobalQueue._endOptimism`, from `flush` after the heap, before the verdict) supersedes with the node's own truth once `_acted` and no `_actions` remain and nothing authoritative blocks (#3427). -**Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. **Body-end corollary (2026-09-14, #3427):** the action bodies were the optimism's justification; once they have all ended, an override still in force is superseded by the truth already at hand — the staged value an equal (A17-silent) landing left, else the committed value — exactly as an arriving differing truth would be, _unless_ the transaction is still waiting on authoritative work: an override node's own source in flight (that answer confirms or supersedes on arrival), or a held flight that does not derive from an override (a plain load the action asked for; a co-written `saving` flag stays through it — the optimistic world is one, A17). Flights that derive from an override are questions about a guess that is about to revert, and nobody reads their answer: the graph re-derives from the truth as the transaction's held work and settles when _that_ lands, so the correction starts at the body's end instead of after the obsolete flight (which used to reveal the obsolete optimistic frame first, then revert and re-ask — a waterfall with a flash). Display is unchanged: the override stays on screen until the commit (c). Optimistic store edits keep the settle-then-revert order (their truth is a base layer under an overlay, with no tracked/displayed split), and companions snap at settlement. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). +**Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). **(d) before the first commit (2026-09-15, visibility oracle):** the verdict holds for a node that has never committed — its first landing held by a reveal that never landed — because the override is the observable value; A19 exception (1) ("uninitialized is loading, not pending: no observable value exists") does not apply under a displayed override. Pinned: `tests/superseded-before-first-commit.test.ts`. A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. **Body-end corollary (2026-09-14, #3427):** the action bodies were the optimism's justification; once they have all ended, an override still in force is superseded by the truth already at hand — the staged value an equal (A17-silent) landing left, else the committed value — exactly as an arriving differing truth would be, _unless_ the transaction is still waiting on authoritative work: an override node's own source in flight (that answer confirms or supersedes on arrival), or a held flight that does not derive from an override (a plain load the action asked for; a co-written `saving` flag stays through it — the optimistic world is one, A17). Flights that derive from an override are questions about a guess that is about to revert, and nobody reads their answer: the graph re-derives from the truth as the transaction's held work and settles when _that_ lands, so the correction starts at the body's end instead of after the obsolete flight (which used to reveal the obsolete optimistic frame first, then revert and re-ask — a waterfall with a flash). Display is unchanged: the override stays on screen until the commit (c). Optimistic store edits keep the settle-then-revert order (their truth is a base layer under an overlay, with no tracked/displayed split), and companions snap at settlement. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). **History (superseded mechanism, kept verbatim).** The 2026-07-07b mechanical model — replaced by the 2026-09-09 supersession, which the statement now leads with; take the mechanism from the statement, not from here: Mechanically: authoritative values arriving under an active override hold in `_pendingValue` like any other transition write and **elevate to `_value` only at their transition's commit** (`_value` changes at commit points, period); the elevation is unobservable under the override mask (A17); reverting is a pure drop — there is no revert target and reverts commit nothing. This supersedes the earlier "bound to its own async source, not its transition" formulation, which was implemented by escaping the transition commit (revert-target commit at revert) and allowed a mid-flight arrival to reveal before its own transition completed. @@ -66,11 +66,11 @@ Sync derivations of transition-held sources are visible through `latest()`/`isPe ### A29. A tracked read served a live transaction's staged value enters that transaction -**Status:** **ruled** 2026-09-13 (#3408) — maintainer ruling, Cluster 4 triage: a value derived from the held world is that transaction's work, whichever path first read it -**Pinned by:** `tests/held-conditional-memo.test.ts` (#3408: a memo whose branch flips mainline and starts reading a held signal reveals with it, not before) -**Mechanism (index, 2026-09-14):** `enterStagedRead` on each of `read()`'s value selections that return `_pendingValue` (both fast paths and the slow path) → `globalQueue.initTransition(el._transition)`; no-op for the ambient batch (`_transition` null), the active transaction, and a probe read (`pendingCheckActive`). +**Status:** **ruled, amended in place** 2026-09-13 (#3408) — maintainer ruling, Cluster 4 triage: a value derived from the held world is that transaction's work, whichever path first read it; amended 2026-09-14 (creation-time form — "born held"; the entry is the pass's, never the mainline block's) +**Pinned by:** `tests/held-conditional-memo.test.ts` (#3408: a memo whose branch flips mainline and starts reading a held signal reveals with it, not before); `tests/born-held.test.ts` (creation-time form: fresh memo + effect held, fresh direct effect shows committed, unrelated write after the mount stays mainline, untracked read throws until commit); `tests/visibility-oracle.test.ts` (published column, held and superseded states) +**Mechanism (index, 2026-09-14):** `enterStagedRead` on each of `read()`'s value selections that return `_pendingValue` (both fast paths and the slow path) → `globalQueue.initTransition(el._transition)`; no-op for the ambient batch (`_transition` null), the active transaction, and a probe read (`pendingCheckActive`). Creation-time form: outside a flush `enterStagedRead` records the transaction (`stagedEntry`) instead of entering; `recompute` stages the pass's node into it (`_transition` stamped, pushed to its `_pendingNodes`, `STATUS_UNINITIALIZED` kept), adds an effect to its `_gatedSubs` and skips the synchronous first run (`effect()`); `commitPendingNode` initializes it; `read()` holds readers of a node with a staged value and no committed one. Verdict pulls (`GlobalQueue._verdictPull`) and optimistic-posture nodes keep the entering path. -A tracked computation served a node's staged `_pendingValue` — a value a live transaction holds — derives from that transaction's world, so its pass enters the transaction and its result is held with it. This is the read twin of the two entries that already existed, `setSignal` on a stamped node and `recompute` of a stamped node, and closes the gap between them: a conditional memo whose branch flipped mainline (`panel = show() ? count() : "hidden"`) started reading a held `count`, was served the staged value (non-stale readers keep speculation), and published a value derived from the held world into the mainline frame — `Panel: 1` beside `Count: 0`. A stale (render) reader is unchanged: the reveal carve-out (A15) serves it the committed value with no entanglement, which is why the same shape written as a plain JSX expression already showed a coherent frame. A probe (`isPending(() => x())`) observes, it does not derive, so it enters nothing (A23). +A tracked computation served a node's staged `_pendingValue` — a value a live transaction holds — derives from that transaction's world, so its pass enters the transaction and its result is held with it. This is the read twin of the two entries that already existed, `setSignal` on a stamped node and `recompute` of a stamped node, and closes the gap between them: a conditional memo whose branch flipped mainline (`panel = show() ? count() : "hidden"`) started reading a held `count`, was served the staged value (non-stale readers keep speculation), and published a value derived from the held world into the mainline frame — `Panel: 1` beside `Count: 0`. A stale (render) reader is unchanged: the reveal carve-out (A15) serves it the committed value with no entanglement, which is why the same shape written as a plain JSX expression already showed a coherent frame. A probe (`isPending(() => x())`) observes, it does not derive, so it enters nothing (A23). **Creation-time form (amended 2026-09-14, "born held"):** the same rule for a memo or effect CREATED from mainline code while the hold is live — a component mounting on a click while an action is in flight. Its creation pass is served the staged value and derives from the transaction's world, so its result is the transaction's: staged into it, committed with it, and — for an effect — first run by its commit; it has no committed value until then (an untracked read of it throws `NotReadyError`, A19 exception 1; a stale reader of it cannot fall back to the committed frame and enters instead). A fresh reader that reads the held node DIRECTLY is a stale reader of a parallel transaction and shows the committed value as before (A15) — so a mainline mount shows the committed frame for direct bindings and holds derived ones until the commit reveals both. The entry is the pass's alone: `activeTransition` and the ambient batch are never touched from creation code, so a write made after the mount is a mainline write. (Maintainer, on the earlier direct-commit: "new creation wouldn't be on screen anyway… we could just be preemptive" — true for nodes the transaction itself creates, which stay inside it; a mainline mount IS on screen.) Before this the creation pass direct-committed the staged value into the mainline frame, and the ambient entry swallowed the handler's later writes into the action. ### A31. A memo computes under its own lane posture, never its puller's diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 1d8c6da4b..12e06e677 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -293,6 +293,11 @@ export function recompute(el: Computed, create: boolean = false): void { // a pending frame to live observers of the verdict (#2990). const wasLoading = el._loading; + // Creation-time A29 (see enterStagedRead): a pass outside a flush that is + // served a live transaction's staged value records the transaction here + // and is staged INTO it below — "born held" — instead of committing. + const prevStagedEntry = stagedEntry; + stagedEntry = null; const oldcontext = context; context = el; el._depsTail = null; @@ -389,7 +394,9 @@ export function recompute(el: Computed, create: boolean = false): void { // its body gates on is either _statusFlags or lives in the cold // extension — no extension, no status to clear. (_x from an unrelated // installer just makes clearStatus a cheap re-verified no-op.) - if (el._statusFlags !== 0 || el._x !== null) clearStatus(el, create); + // A node born held keeps STATUS_UNINITIALIZED until its transaction + // commits: it has no committed value, and read() holds its readers. + if (el._statusFlags !== 0 || el._x !== null) clearStatus(el, create && stagedEntry === null); // _optimisticLane is only ever assigned by engine paths (CONFIG_HAS_LANE // is their sticky presence mark). if (el._config & CONFIG_HAS_LANE && el._x?._optimisticLane) GlobalQueue._laneAsyncSettled!(el); @@ -439,6 +446,11 @@ export function recompute(el: Computed, create: boolean = false): void { el._flags = REACTIVE_NONE | (create ? el._flags & REACTIVE_SNAPSHOT_STALE : 0); context = oldcontext; } + // The cast re-widens: TS narrowed `stagedEntry` to `null` at the reset + // above and does not invalidate that across the compute call that + // `enterStagedRead` runs under. No emitted code. + const bornHeld = stagedEntry as Transition | null; + stagedEntry = prevStagedEntry; if (!el._x?._error) { // Observe-tier fan-in (HUGE_FAN_IN): the validated prefix [_deps.._depsTail] @@ -536,7 +548,7 @@ export function recompute(el: Computed, create: boolean = false): void { const prevVisible = hasOverride ? el._x?._overrideValue : undefined; if ( - create || + (create && bornHeld === null) || // Plain sync flush (no transition on either side) commits effect // values directly — the pending round-trip (queuePendingNode + // commitPendingNodes) exists to sequence transition reveals, and @@ -546,6 +558,7 @@ export function recompute(el: Computed, create: boolean = false): void { // not the stashed queues, so a staged value would hand the immediate // apply stale state — see CONFIG_DIRECT_COMMIT. (isEffect && + bornHeld === null && (activeTransition !== el._transition || activeTransition === null || el._config & CONFIG_DIRECT_COMMIT)) || @@ -576,6 +589,17 @@ export function recompute(el: Computed, create: boolean = false): void { } else { el._pendingValue = value; if (__DEV__) devTrackHeldPending(el); + if (bornHeld !== null) { + // Born held: the pass ran from mainline and derived from this + // transaction's staged world (enterStagedRead). Its value is the + // transaction's — staged into it directly, stamped, and committed + // with it; mainline's batch never sees it. A born-held effect + // skips its synchronous first run (effect()) and is replayed by + // the commit like a stale reader that showed the committed frame. + el._transition = bornHeld; + bornHeld._pendingNodes.push(el); + if (isEffect) bornHeld._gatedSubs.add(el); + } // A window landing that gets held re-opens the window until the hold // commits — the verdict's held-value branch is window-gated (#2990). if (wasLoading) el._loading = true; @@ -706,7 +730,9 @@ export function recompute(el: Computed, create: boolean = false): void { // zombies deferred at the top are superseded on that same frame. Its // commit rides the transaction, not this flush: release them here rather // than let two generations render at once. - let held = needsPendingCommit && (!create || (el._statusFlags & STATUS_PENDING) !== 0); + let held = + needsPendingCommit && + (!create || bornHeld !== null || (el._statusFlags & STATUS_PENDING) !== 0); if (held && (!el._transition || hasOverride)) queuePendingNode(el); else if ( held && @@ -718,7 +744,8 @@ export function recompute(el: Computed, create: boolean = false): void { } if (held) el._config |= CONFIG_HELD_CHILDREN; else el._config &= ~CONFIG_HELD_CHILDREN; - if (el._transition && isEffect && activeTransition !== el._transition) { + // (A born-held pass IS the transaction's staged view — nothing to refresh.) + if (el._transition && isEffect && activeTransition !== el._transition && bornHeld === null) { // The re-run refreshes the transaction's STAGED view (_pendingValue); the // value this pass published in _value belongs to the run that just // finished. Keep that ownership, or the effect phase parks a @@ -1469,9 +1496,36 @@ function heldFromStale(el: Signal | Computed, c: Computed): boole * not derive) and a no-op for the ambient batch (`_transition` null) or the * transaction already active. */ -function enterStagedRead(el: Signal | Computed): void { +/** The transaction a pass running OUTSIDE a flush was served a staged value + * from (A29, creation-time form). Inside a flush the pass enters through + * initTransition; outside one — a memo or effect created from mainline code + * while a hold is live — entering would leave `activeTransition` and the + * batch pointed at the transaction for the rest of the synchronous block, + * so an unrelated write made after the mount was held with someone else's + * action. The entry is the pass's alone: recompute stages the node into the + * transaction (born held) and mainline is never touched. */ +let stagedEntry: Transition | null = null; + +export function enterStagedRead(el: Signal | Computed): void { const t = el._transition; - if (t !== null && t !== activeTransition && !pendingCheckActive) globalQueue.initTransition(t); + if (t === null || t === activeTransition || pendingCheckActive) return; + // Verdict machinery (GlobalQueue._verdictPull: companion creation and the + // latest()/isPending() pulls — the latest() shadow is created before it is + // marked optimistic, so the bit alone cannot tell) and optimistic nodes + // (own lane posture, A31) read staged truth by design; they keep the + // entering path. + // (`context` is non-null here: every caller selected a value for a reader.) + const ctx = context as Computed; + if ( + activeTransition === null && + !globalQueue._running && + !GlobalQueue._verdictPull && + ctx._flags & REACTIVE_RECOMPUTING_DEPS && + !(ctx._config & CONFIG_OPTIMISTIC) && + (stagedEntry === null || stagedEntry === t) + ) + stagedEntry = t; + else globalQueue.initTransition(t); } export function readNodeFast(el: Signal): T | typeof READ_SLOW { @@ -1729,13 +1783,21 @@ export function read(el: Signal | Computed): T { // (The lane-context clause lives with the engine.) Children-forbidden readers // (createTrackedEffect / onSettled callbacks) get committed visibility — see // readNodeFast (#3006). + // A node born held (recompute) has a staged value and no committed one: a + // stale reader cannot fall back to the committed frame, so it takes the + // staged value and enters like a tracked reader (A29); an untracked reader + // has nothing to serve and holds (A19 exception 1). + const noCommitted = + el._pendingValue !== NOT_PENDING && + ((el as Computed)._statusFlags & STATUS_UNINITIALIZED) !== 0; + if (noCommitted && !c) throw new NotReadyError(null); const value = !c || (currentOptimisticLane !== null && GlobalQueue._laneReadsCommitted!(el, owner, c as Computed)) || el._pendingValue === NOT_PENDING || c._config & CONFIG_CHILDREN_FORBIDDEN || - (stale && heldFromStale(el, c as Computed)) || + (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 diff --git a/packages/signals/src/core/effect.ts b/packages/signals/src/core/effect.ts index 5139e4e06..731ecca7c 100644 --- a/packages/signals/src/core/effect.ts +++ b/packages/signals/src/core/effect.ts @@ -1,3 +1,4 @@ +import { NOT_PENDING } from "./constants.js"; import { CONFIG_AUTO_DISPOSE, CONFIG_CHILDREN_FORBIDDEN, @@ -72,7 +73,11 @@ export function effect( options ) as Effect; recompute(node, true); + // A first pass that derived from a live transaction's staged world was + // staged into that transaction (recompute: born held); the transaction's + // commit replays this effect. Its first run is not this creation's (A29). !options?.defer && + node._pendingValue === NOT_PENDING && (node._type === EFFECT_USER || options?.schedule ? node._queue.enqueue(node._type, runEffect.bind(null, node)) : runEffect(node, LANE_RUN)); diff --git a/packages/signals/src/core/optimistic.ts b/packages/signals/src/core/optimistic.ts index a0f1b941b..f10e8af94 100644 --- a/packages/signals/src/core/optimistic.ts +++ b/packages/signals/src/core/optimistic.ts @@ -29,7 +29,7 @@ import { CONFIG_OVERRIDE_SUPERSEDED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; -import { currentOptimisticLane, latestReadActive, stale, ext } from "./core.js"; +import { currentOptimisticLane, enterStagedRead, latestReadActive, stale, ext } from "./core.js"; import { NotReadyError } from "./error.js"; import { devCheckMergedLaneEmpty, devTrackHeldPending, devTrackOptimistic } from "./invariants.js"; import { @@ -340,7 +340,11 @@ function endOptimism(transition: Transition): boolean { function supersededRead(el: OptimisticNode): unknown { if (stale && el._transition && activeTransition !== el._transition) return unwrapOverride(el._x?._overrideValue); - return el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; + if (el._pendingValue === NOT_PENDING) return el._value; + // The staged truth is a staged read like any other (A29): the pass that + // derives from it is the transaction's. + enterStagedRead(el); + return el._pendingValue; } /** diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index f0c1a0aef..5c6f3bbd5 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -689,6 +689,9 @@ export class GlobalQueue extends Queue { * transaction — then the displayed override, as it keeps a foreign * transaction's committed value over its staged write. */ static _supersededRead: ((el: Signal | Computed) => unknown) | null = null; + /** Verdict-layer recompute in progress (companion creation, latest()/ + * isPending() pulls): never born held — see core.ts enterStagedRead. */ + static _verdictPull = false; /** setSignal's authoritative (projection-write) landing on an override- * covered node (#3331 store twin): stage the truth for its transaction's * commit whatever its relation to the committed value — a landing equal to @@ -1100,6 +1103,8 @@ function commitPendingNode(n: Signal): void { if (n._pendingValue !== NOT_PENDING) { n._value = n._pendingValue as any; n._pendingValue = NOT_PENDING; + // A node born held (recompute) initializes at this commit. + c._statusFlags! &= ~STATUS_UNINITIALIZED; // Set _modified for effects, but not for tracked effects (they handle their own scheduling) if ((n as any)._type && (n as any)._type !== EFFECT_TRACKED) (n as any)._modified = true; // A quiet re-ask classification preserved through a held landing dies diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 0aee57f87..54899ce17 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -219,11 +219,14 @@ function computePendingState(el: Signal | Computed): boolean { // the window's own landing in flight to its commit — verdict-quiet like the // rest of the window (the UNINITIALIZED check suppresses exactly this frame // for windowless first loads; born-committed nodes need their own gate, #2990). - if ( - el._pendingValue !== NOT_PENDING && - !(comp._statusFlags & STATUS_UNINITIALIZED) && - !comp._loading - ) { + if (el._pendingValue !== NOT_PENDING && !comp._loading) { + // A18 (d): under a displayed override the observable value is the + // override, so the verdict is "the arrived truth differs from it" — + // even before the node's first commit. The UNINITIALIZED suppression + // below is A19 exception (1), "no observable value exists to be + // 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)) return ( !el._equals || !el._equals(el._pendingValue as any, unwrapOverride(el._x?._overrideValue)) @@ -232,7 +235,7 @@ function computePendingState(el: Signal | Computed): boolean { // classification survives the landing (asyncWrite) and dies with the // commit (commitPendingNode) — verdict-quiet through the reveal, like // the loading window above (#3178). - if (!comp._x?._reask) return true; + if (!(comp._statusFlags & STATUS_UNINITIALIZED) && !comp._x?._reask) return true; } return newQuestionInFlight(comp); } @@ -364,7 +367,12 @@ function getLatestValueComputed(el: Signal | Computed): Computed { setPendingCheckActive(false); const prevContext = context; setContextInternal(null); // Detach from owner so it isn't disposed with effects - lvc = optimisticComputed(() => read(el), { ownedWrite: true }); + GlobalQueue._verdictPull = true; + try { + lvc = optimisticComputed(() => read(el), { ownedWrite: true }); + } finally { + GlobalQueue._verdictPull = false; + } ext(el)._latestValueComputed = lvc; el._config |= CONFIG_HAS_COMPANIONS; markFirewallChildCompanions(el); @@ -417,10 +425,12 @@ function latestRead(el: Signal | Computed): T { // latest(() => isPending(x)) from true to false). const prevCheck = pendingCheckActive; setPendingCheckActive(false); + GlobalQueue._verdictPull = true; try { prepareComputed(pendingComputed as Computed, true); } finally { setPendingCheckActive(prevCheck); + GlobalQueue._verdictPull = false; } } value = read(pendingComputed); @@ -482,8 +492,14 @@ function pendingCheckRead( firewall: Computed | null ): void { setPendingCheckActive(false); - if (typeof (el as Partial>)._fn === "function") - prepareComputed(el as Computed, true); + if (typeof (el as Partial>)._fn === "function") { + GlobalQueue._verdictPull = true; + try { + prepareComputed(el as Computed, true); + } finally { + GlobalQueue._verdictPull = false; + } + } const ownerStatus = (owner as Computed)._statusFlags!; if ( c && diff --git a/packages/signals/tests/born-held.test.ts b/packages/signals/tests/born-held.test.ts new file mode 100644 index 000000000..70b9e6394 --- /dev/null +++ b/packages/signals/tests/born-held.test.ts @@ -0,0 +1,148 @@ +/** + * A29, creation-time form — "born held". + * + * A memo or effect created from MAINLINE code while a transaction holds a + * value it reads (a component mounting on a click while an action is in + * flight) is served the staged value and derives from the transaction's + * world. Its result is the transaction's: staged into it, committed with it, + * and — for an effect — first run by its commit. Nothing about the mainline + * block that created it changes: `activeTransition` and the ambient batch are + * untouched, so a write made after the mount is a mainline write. + * + * Before: recompute's creation pass always direct-committed, so the fresh + * memo published the held value into the mainline frame beside pre-existing + * readers showing the committed one; and enterStagedRead entered the + * transaction ambiently, so an unrelated write made after the mount was + * swallowed into the action. + */ +import { describe, expect, it } from "vitest"; +import { + NotReadyError, + action, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush +} from "../src/index.js"; + +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +function heldSignal() { + const [x, setX] = createSignal(0); + const pre: number[] = []; + createRoot(() => { + createRenderEffect(x, v => { + pre.push(v); + }); + }); + flush(); + let release!: () => void; + const run = action(function* () { + setX(1); + yield new Promise(r => (release = r)); + }); + run(); + flush(); + return { x, pre, release }; +} + +describe("A29 born held: nodes created mainline during a hold", () => { + it("a fresh memo + render effect derive from the held world and are held with it; a fresh direct effect shows the committed frame", async () => { + const { x, pre, release } = heldSignal(); + expect(pre).toEqual([0]); + + const viaMemo: number[] = []; + const direct: number[] = []; + const derived: number[] = []; + createRoot(() => { + const m = createMemo(() => { + const v = x(); + derived.push(v); + return v; + }); + createRenderEffect(m, v => { + viaMemo.push(v); + }); + createRenderEffect(x, v => { + direct.push(v); + }); + }); + flush(); + // The memo's pass derived from the staged 1 (A29) ... + expect(derived).toEqual([1]); + // ... and its effect published nothing: the value is the transaction's. + expect(viaMemo).toEqual([]); + // The direct effect is a stale reader of a parallel transaction: committed. + expect(direct).toEqual([0]); + expect(pre).toEqual([0]); + + release(); + await settle(); + // The commit reveals everything at once. + expect(viaMemo).toEqual([1]); + expect(direct).toEqual([0, 1]); + expect(pre).toEqual([0, 1]); + }); + + it("an unrelated mainline write after the mount is not swallowed into the action", async () => { + const { x, release } = heldSignal(); + const [y, setY] = createSignal(0); + const yLog: number[] = []; + createRoot(() => { + createRenderEffect(y, v => { + yLog.push(v); + }); + }); + flush(); + yLog.length = 0; + + // click handler: mount something that reads the held x, then an unrelated write + createRoot(() => { + createMemo(() => x()); + }); + setY(1); + flush(); + expect(yLog).toEqual([1]); + expect(y()).toBe(1); + + release(); + await settle(); + }); + + it("an untracked read of a born-held memo has no committed value to serve and throws NotReady until the commit", async () => { + const { x, release } = heldSignal(); + let m!: () => number; + createRoot(() => { + m = createMemo(() => x()); + }); + expect(() => m()).toThrow(NotReadyError); + release(); + await settle(); + expect(m()).toBe(1); + }); + + it("inside a flush nothing changes: a memo whose branch flips mainline still enters (#3408)", async () => { + const { x, release } = heldSignal(); + const [fixed, setFixed] = createSignal(true); + const log: string[] = []; + createRoot(() => { + const sel = createMemo(() => (fixed() ? -1 : x())); + createRenderEffect(sel, v => { + log.push(`sel:${v}`); + }); + }); + flush(); + expect(log).toEqual(["sel:-1"]); + setFixed(false); // mainline flip → the pass reads the held x → enters the transaction + flush(); + expect(log).toEqual(["sel:-1"]); + release(); + await settle(); + expect(log).toEqual(["sel:-1", "sel:1"]); + }); +}); diff --git a/packages/signals/tests/superseded-before-first-commit.test.ts b/packages/signals/tests/superseded-before-first-commit.test.ts new file mode 100644 index 000000000..c55ad0f79 --- /dev/null +++ b/packages/signals/tests/superseded-before-first-commit.test.ts @@ -0,0 +1,85 @@ +/** + * A18 (d) before the node's first commit. + * + * An optimistic computed whose FIRST landing was held — a downstream async + * memo read it, opened a reveal, and never landed — has no committed value + * (`_value` undefined, STATUS_UNINITIALIZED) when an action later writes an + * override and its own source lands a differing truth. The override is the + * observable value, so the verdict is "the arrival differs" (A18 d); the + * uninitialized suppression is A19 exception (1), whose premise — no + * observable value — an override defeats. Found by the visibility oracle. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +function graph() { + const holds: Array<() => void> = []; + const never = () => new Promise(r => holds.push(r as () => void)); + const [value, setValue] = createSignal(0); + const fetches: Array<() => void> = []; + const flights: Array<() => void> = []; + let x!: () => number; + let setX!: (v: number) => void; + createRoot(() => { + [x, setX] = createOptimistic(() => { + const v = value(); + return new Promise(r => fetches.push(() => r(v * 2))); + }); + const downstream = createMemo(() => { + const n = x(); + return new Promise(r => flights.push(() => r(`${n}!`))); + }); + createRenderEffect(downstream, () => {}); + }); + return { + x, + setX, + setValue, + fetches, + flights, + never, + async release() { + for (const r of holds.splice(0)) r(); + for (const f of flights.splice(0)) f(); + await settle(); + await settle(); + } + }; +} + +describe("A18 (d) before the first commit", () => { + it("isPending reads true when the arrival differs from the override, although the node never committed", async () => { + const g = graph(); + flush(); + g.fetches.shift()!(); // x's first landing (0) — held by the downstream reveal, which never lands + await settle(); + action(function* () { + g.setValue(1); + g.setX(3); // wrong guess; the truth will be 2 + yield g.never(); + })(); + flush(); + g.fetches.shift()!(); // own source lands 2 ≠ 3 + await settle(); + expect(g.x()).toBe(3); // the displayed override (A18 c) + expect(latest(g.x)).toBe(2); // the arrived truth (A18 d) + expect(isPending(g.x)).toBe(true); // it differs + await g.release(); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 2387586cc..685ae820d 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -293,7 +293,22 @@ describe("pay-for-use tree-shaking (#2883)", () => { // instead of being cancelled when the parking batch is the transaction // (#3444; one guard in cancelZombieRecompute). Measured at 23,419 on top // of #3442 (23,365 → 23,419). - expect(minifiedBytes).toBeLessThan(23_450); + // CONSCIOUS BUMP (2026-09-14): +316 B for A29's creation-time form — + // "born held". A memo or effect created from MAINLINE code while a + // transaction holds a value it reads used to direct-commit its creation + // pass (`create ||`) — publishing the held value into the mainline frame + // beside readers showing the committed one — and `enterStagedRead` + // entered the transaction ambiently from creation code, so an unrelated + // write made after the mount was swallowed into the action. Now the pass + // records the transaction (`stagedEntry`) and is staged INTO it: stamped, + // pushed to its pending nodes, `STATUS_UNINITIALIZED` kept until its + // commit, effects skipped on creation and replayed by the commit + // (`_gatedSubs`); read() holds readers of a node with a staged value and + // no committed one. Core-retained by necessity: recompute's create arms, + // read()'s selection, commitPendingNode. Measured at 23,681 on top of + // #3442; 23,753 rebased over #3443/#3444 (23,419 → 23,753, +334 — the + // two land on the same notifyStatus/recompute seams). + expect(minifiedBytes).toBeLessThan(23_800); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { diff --git a/packages/signals/tests/visibility-oracle.test.ts b/packages/signals/tests/visibility-oracle.test.ts index d9065a83a..e217583fb 100644 --- a/packages/signals/tests/visibility-oracle.test.ts +++ b/packages/signals/tests/visibility-oracle.test.ts @@ -325,10 +325,9 @@ const STATES: State[] = [ 1, "A29: a tracked pass served the staged value derives from the transaction's world" ), - published: violation( + published: rule( HELD, - 1, - "A29: a fresh mainline memo + render effect created during the hold publishes the HELD value into the mainline frame; a fresh effect reading the signal directly, and pre-existing readers, correctly show 0" + "A29 (born held): a memo created mainline during the hold derives from the transaction’s world and is staged into it; the render effect over it is replayed by the commit, publishing nothing before" ), preexisting: rule(HELD, "A19 (i): the held write is not on screen"), staleForeign: rule( @@ -429,10 +428,9 @@ const STATES: State[] = [ "A18 (c): untracked reads keep the override until the transaction commits" ), derivesFrom: rule(2, "A18 (b): tracked derivations recompute from the arrived value"), - published: violation( + published: rule( HELD, - 2, - "A18 (c): a fresh mainline memo + render effect over the superseded node publishes the TRUTH (2) while a fresh DIRECT render effect in the same root publishes the override (3) and every pre-existing reader holds — a tear between readers of one frame" + "A18 (c) / A29 (born held): a fresh mainline memo over the superseded node derives from the staged truth and is held with the transaction; the frame keeps the override" ), preexisting: rule(HELD, "A18 (c): the applied frame keeps the override until commit"), staleForeign: rule( @@ -446,7 +444,7 @@ const STATES: State[] = [ } }, { - name: "superseded, downstream never initialized (its first flight never lands)", + name: "superseded before its first commit (the first landing was held by a downstream reveal that never landed)", async build(installStale) { const built = supersededGraph(false); await built.prime(); @@ -457,7 +455,7 @@ const STATES: State[] = [ expect: { untracked: rule(3, "A18 (c)"), derivesFrom: rule(2, "A18 (b)"), - published: violation(HELD, 2, "as above"), + published: rule(HELD, "A18 (c) / A29 (born held)"), preexisting: observed( HELD, "this reader (created after the node initialized) holds. A render effect on the node created BEFORE its first landing published the truth (2) at the supersession in a side probe — while untracked reads still served 3 — so the hold here is shape-dependent; follow-up" @@ -465,10 +463,9 @@ const STATES: State[] = [ staleForeign: observed(3, "displays the override, as in the initialized case"), childrenForbidden: rule(3, "A32"), latest: rule(2, "A18 (d)"), - isPending: violation( + isPending: rule( true, - false, - "A18 (d): the arrival differs from the override yet the verdict is false — the uninitialized downstream reporter is not counted as observing the flight" + "A18 (d): pending iff the arrival differs from the displayed override — even before the node's first commit (the override is the observable value; A19 exception 1 does not apply)" ), authoritative: rule(2, "A17 carve-out") } diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 28ebbf7da..679f49510 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -228,7 +228,16 @@ module.exports = [ // lane-dirtied zombie runs instead of being cancelled; +54 B minified in // the in-package floor (23,365 -> 23,419). The first cut (stamp-only // entanglement) fit at 8690; the holds carve-out is what tips the cap. - limit: "8.75 KB", + // Born held (A29 creation-time form, 2026-09-14): 8.70 -> 8.85 KB, + // measured at 8796 B against 8691 (+105) — a memo or effect created from + // mainline while a hold is live stages INTO the transaction instead of + // committing (recompute), the read-side "no committed value" rule, the + // commit-time init, and enterStagedRead's pass-scoped path (no ambient + // entry from creation code: an unrelated write after the mount stays + // mainline). +316 B minified in the in-package floor (23,365 -> 23,681). + // Rebased over #3443/#3444 (2026-09-15): measured at 8820 B against + // `next`'s 8708 (+112); 23,419 -> 23,753 minified. + limit: "8.85 KB", modifyEsbuildConfig }, { @@ -458,7 +467,10 @@ module.exports = [ // 15.70 KB, measured at 15679 B — pending propagation onto a held memo enters // its transaction, and a lane-dirtied zombie runs instead of being // cancelled (+54 B minified in the in-package floor); see the core floor note. - limit: "15.70 KB", + // Born held (2026-09-14): 15.65 -> 15.80 KB, measured at 15750 B against + // 15624 (+126); rebased over #3443/#3444: 15743 B against `next`'s 15658 + // (+85); see the core floor note. + limit: "15.80 KB", modifyEsbuildConfig }, { @@ -574,7 +586,11 @@ module.exports = [ // 11.10 KB, measured at 11066 B — pending propagation onto a held memo enters // its transaction, and a lane-dirtied zombie runs instead of being // cancelled (+54 B minified in the in-package floor); see the core floor note. - limit: "11.10 KB", + // Born held (2026-09-14): 11.05 -> 11.25 KB, measured at 11196 B against + // 11050 (+146 — the core seams plus the verdict pulls' `_verdictPull` + // brackets and supersededRead's entry); rebased over #3443/#3444: 11213 B + // against `next`'s 11084 (+129); see the core floor note. + limit: "11.25 KB", modifyEsbuildConfig }, { @@ -660,7 +676,10 @@ module.exports = [ // Held-input rules (#3408, #3410; 2026-09-14, on top of #3434): 11.40 -> // 11.45 KB, measured at 11417 B against `next`'s 11368 (+49) — `enterStagedRead` on read()'s value // selections and the deferred dependency trim; see the core floor note. - limit: "11.45 KB", + // Born held (A29 creation-time form, #3451; 2026-09-15): 11.45 -> 11.65 KB, + // measured at 11566 B against `next`'s 11407 (+159); the signals-core + // bytes from the core floor note, nothing app-side. + limit: "11.65 KB", modifyEsbuildConfig }, { @@ -770,7 +789,10 @@ module.exports = [ // core floor note. // Memo lane posture (#3442, 2026-09-14): no bump, measured at 18854 B on // the rebase over #3438 (+12 B minified; brotli noise absorbs it). - limit: "18.90 KB", + // Born held (A29 creation-time form, #3451; 2026-09-15): 18.90 -> 19.10 KB, + // measured at 19023 B against `next`'s 18899 (+124); the signals-core + // bytes from the core floor note, nothing app-side. + limit: "19.10 KB", modifyEsbuildConfig }, { @@ -923,7 +945,10 @@ module.exports = [ // Held-input rules (#3408, #3410; 2026-09-14, on top of #3434): 28.65 -> // 28.70 KB, measured at 28680 B against `next`'s 28602 (+78) — `enterStagedRead` on read()'s value // selections and the deferred dependency trim; see the core floor note. - limit: "28.70 KB", + // Born held (A29 creation-time form, #3451; 2026-09-15): 28.70 -> 28.85 KB, + // measured at 28746 B against `next`'s 28580 (+166); the signals-core + // bytes from the core floor note, nothing app-side. + limit: "28.85 KB", modifyEsbuildConfig }, { @@ -1001,7 +1026,10 @@ module.exports = [ // selections and the deferred dependency trim; see the core floor note. // Effect arm of A30 (#3438, 2026-09-14): 14.25 -> 14.30 KB, measured at // 14251 B against `next`'s 14245 (+6); see the core floor note. - limit: "14.30 KB", + // Born held (A29 creation-time form, #3451; 2026-09-15): 14.30 -> 14.45 KB, + // measured at 14384 B against `next`'s 14257 (+127); the signals-core + // bytes from the core floor note, nothing app-side. + limit: "14.45 KB", modifyEsbuildConfig }, { @@ -1085,7 +1113,10 @@ module.exports = [ // 15.70 KB, measured at 15681 B — pending propagation onto a held memo enters // its transaction, and a lane-dirtied zombie runs instead of being // cancelled (+54 B minified in the in-package floor); see the core floor note. - limit: "15.70 KB", + // Born held (A29 creation-time form, #3451; 2026-09-15): 15.70 -> 15.90 KB, + // measured at 15811 B against `next`'s 15679 (+132); the signals-core + // bytes from the core floor note, nothing app-side. + limit: "15.90 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -1187,7 +1218,10 @@ module.exports = [ // #3426/#3427/#3407 (2026-09-14): 27.16 -> 27.25 KB, measured at 27203 B // against `next`'s 27109 (+94 — the core seams on the observe artifacts); // see the core floor note. - limit: "27.25 KB", + // Born held (A29 creation-time form, #3451; 2026-09-15): 27.25 -> 27.45 KB, + // measured at 27350 B against `next`'s 27227 (+123); the signals-core + // bytes from the core floor note, nothing app-side. + limit: "27.45 KB", modifyEsbuildConfig: observeEsbuildConfig }, {