From e30fcc77876078f29b778efaa47ec130b16b8af3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 08:28:21 -0700 Subject: [PATCH] fix(signals): a derivation's untracked read of a held store value is born held (A29 at the store's untracked paths) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of DESIGN-CONSOLIDATION move 3b. Core read() serves a staged value to a deriving reader and enters its transaction in the same arm — an untracked read inside a memo is still that memo's read (context persists under untrack), so a mainline memo over a held signal is born held. The store's untracked paths — nodeValue, pendingBackingVisible, readSource's adoption-hold view — restated the value half and dropped the entry: a mainline memo's or user effect's untrack(() => s.n) PUBLISHED the action's unrevealed write. They now call enterStagedRead on the arm that serves the staged value; enterStagedRead takes a null node for a backing (the fold's transaction is passed). The two store tunnels (latest() for store keys, authoritativeServe) keep serving without entering, as before. Discovery: the posture matrix gains memoUntracked / effectUntracked reader kinds (621 -> 851 cells) — the kind that sees S4 and S5, and that shows two more store/signal divergences recorded, not ruled, as S6 (staged-ambient write read by a derivation created inside a foreign action) and S7 (optimistic override with the only reader gated away). Commits docs/DESIGN-CONSOLIDATION.md (dropped from next by c7ec862's squash accident) with a ledger of moves 3a/3b so far; the code citations now point at it. Gate: suite green (+14 pins); oracles identical; the 5-reader subset of the matrix identical to next; +1 B minified core, +130 B minified store; two brotli caps ratcheted. Co-authored-by: Claude via Cursor --- .changeset/store-untracked-born-held.md | 5 + packages/signals/docs/DESIGN-CONSOLIDATION.md | 140 +++++++++++++ packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/src/core/core.ts | 14 +- packages/signals/src/store/next/store.ts | 28 ++- .../tests/posture-store-parity.test.ts | 189 ++++++++++++++++++ .../tests/visibility-oracle-posture.test.ts | 31 ++- scripts/size/.size-limit.js | 14 +- 8 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 .changeset/store-untracked-born-held.md create mode 100644 packages/signals/docs/DESIGN-CONSOLIDATION.md diff --git a/.changeset/store-untracked-born-held.md b/.changeset/store-untracked-born-held.md new file mode 100644 index 000000000..417055821 --- /dev/null +++ b/.changeset/store-untracked-born-held.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A memo or user effect created on mainline whose untracked read (`untrack(() => s.n)`, `deep(s)`) is of a store key held by a live action is now born held (A29), as the same read of a signal is: the pass enters the action's transaction and publishes nothing until the action commits. Previously the store's untracked paths served the held value without entering, so a mainline memo published the action's unrevealed write to the screen. Covers keys with and without a node and `reconcile` adoptions held by an action. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md new file mode 100644 index 000000000..f3afed9f7 --- /dev/null +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -0,0 +1,140 @@ +# Consolidation — one implementation per rule + +**Status:** design 2026-09-16 (read-only pass over `next` at `5fa224a4a`, #3479 in), approved to proceed; **in progress** — see §0. Written for a decision; §1–§6 are the design as reviewed, §0 is the ledger. + +## 0. Ledger (2026-09-17) + +| Move | Status | Where | What it found | +| -------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 3a — `retireReporter`, deps scan at `_depsTail` | **merged** | #3496 and follow-ups (O3, fuzzer P1) | Fuzzer 984 / 4 / 12 → 994 / 0 / 6. | +| 3b step 1 — one `unflushed` | **merged** | #3510 (`CONFIG_ADOPTED_UNFLUSHED`) | Closed S1 / O4 for the verdict channels. | +| 3b step 1 — shared predicates | PR #3515 | `readerSeesCommitted` (read's slow tail), `visibleOverride` / one `hasActiveOverride` (core, lanes, verdict ×3, store ×2) | Pure refactor. Matrix, oracles, fuzzer identical; full bundle −72 B minified. | +| 3b step 2 — store node reads select by Rule 1 | PR #3516 | `nodeValue` → `readerSeesCommitted` behind the two store tunnels (`latest()`, `authoritativeServe`) | **S4:** the store's stale-of-foreign twin served committed but skipped the replay registration — a render effect's untracked read of a held key stayed on the pre-action value forever. Invisible to the suite, both oracles, the 621-cell matrix and the fuzzer. | +| 3b step 3 — backing-level replay | PR #3517 | `recordStaleReplay` (lifted from `heldFromStale`) called by `heldFromReader` and the adoption-hold view | S4's no-node and `reconcile`-inside-an-action forms (four store shapes, all failing on `next`). | +| 3b step 4 — A29 at the store's untracked paths | this PR | `nodeValue`, `pendingBackingVisible`, the adoption-hold view call `enterStagedRead` on the arm that serves the staged value; `enterStagedRead(null, txn)` for a backing | **S5:** a mainline memo's / user effect's untracked read of a held store key **published the unrevealed write** while the same read of a signal was born held. Matrix gained `memoUntracked` / `effectUntracked` reader kinds (621 → 851 cells) — the kind that sees it. | +| 3b — recorded, not ruled | open | `posture-store-parity` S6, S7 | **S6:** staged-ambient write, derivation created inside a foreign action: signal publishes committed (A28 / #3510), store publishes pending. **S7:** optimistic store override, only reader gated away: signal still reads the override, store reads committed. | +| 3b step 5 — `pendingBackingVisible`'s own arms, the store tunnels, `overrideRead` / `latestRead` / lane arms → one `serve` | not started | | Needs the lane ruling (§6). | +| 3c — cargo lifecycle | not started | | Wait for the first new "decided at the pass" fix, or for 3b to settle. | + +**Method that is working:** the pure-refactor step finds nothing; handing a twin the shared predicate and asking what changed finds the precondition the twin dropped. Every 3b finding so far (S4, S5) was a store site restating a core rule minus one half of it (the replay registration, the transaction entry) — the pattern §1 predicted, in store code the matrix could not see until it had a reader kind for "an untracked read inside a derivation." + +**Bytes so far (minified):** step 1 −72 B full bundle; step 3 +31 B core / +123 B store; step 4 +1 B core / +130 B store. The fixes cost bytes; the refactors do not. + +## 1. Why + +The last two months' async fixes are ~four rules, each fixed several times at different sites: + +| Rule (stated per outcome) | Sites that each decide it (enforced per site) | Fixes to the same rule | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| Which value does a reader off the hold see | `readNodeFast`, `read` fast block, `read` slow tail, `overrideRead`, `latestRead`, `gatedRead`, `laneReadsCommitted`, `readsHeldCommitted`, store `nodeValue` / `serveDataKey` / `pendingBackingVisible` / `heldFromReader` / `visibleOverride` / `optimisticView` | #3330 #3334 #3460 (two places) A29 (three sites) A28 (four sites) | +| Is this reporter live | `reporterBlocksSource` (one predicate, five stand-ins) + three independent _wake_ sites: `disposeChildren`, `recompute` tail, boundary reset | #3372 #3375 #3426 #3458 #3463 #3488 | +| Dependencies are the committed frame's (A30) | `commitPendingNode` trim, `runEffect` trim, `heldTrims` (unchanged pass), `trimStaleDeps` at pass end | #3410 #3438 #3461 #3469 | +| Decided at the pass, known at the verdict | `CONFIG_HELD_CHILDREN`+`_pendingFirstChild`/`_pendingDisposal`, `_modified`+`_queueStash`, `heldRevealed`, `_gatedSubs`, `heldTrims`, `_contested`, `_flushedStaged` | one bespoke mechanism per fix | + +Two instruments now exist that did not when those fixes were written: the posture matrix (621 enumerated cells: state × posture × reader → served value, entanglement) and the semantic fuzzer (#3446, 20 laws over generated graphs; baseline on this `next`: 984 pass / 4 fail / 12 policy). Both say "zero semantic change" is now a checkable claim rather than a hope, which is the precondition for any of what follows. + +Three open violations are the concrete targets; each is a consequence of the site count: + +- **O3, same-flush form** (fuzzer P1, cases 21/79; pinned `it.fails`): gate closes and source is written in one flush → the reporter's pass runs under the transaction and _stages_ its value, so A30 keeps its previous dep on the memo linked past `_depsTail` until commit; `reporterBlocksSource`'s deps scan walks the whole list, finds the kept dep, and calls the reporter live. The hold keeps the dep that keeps the hold. Rule 2's predicate reading Rule 3's deferral — verified by probe 2026-09-16 (pass ran once; not pending; `_pendingValue = "hidden"`; deps `[show, memo]`, tail after `show`). +- **O4 / S1** (pinned `it.fails`): after same-tick adoption, the signal's `unflushedValue` reads a stamped node with no stash as "flushed, held" and `latest`/`isPending` see a write no flush carried; the store's `flushedStaged` path does not. Two definitions of "unflushed". Rule 1. +- **O2** (recorded, not ruled): creation under a transaction / in boundary content escapes the hold while mainline creation is born held. One rule (A29) implemented at one of its sites. Rule 1 / Rule 4. A ruling question first — the consolidation makes whichever answer is chosen hold everywhere. + +## 2. Inventory (as of `5fa224a4a`) + +Condensed from a read-only walk; line numbers are approximate to ±5 and will drift. + +### Rule 1 — value selection + +Core, in evaluation order per site: + +- `readNodeFast` (`core.ts` ~1699–1737): bail gate → `READ_SLOW` on any special mode (`latestReadActive`, `pendingCheckActive`, `_fn`, `_firewall`, override, snapshot, `activeTransition`, lane, `unflushedStaged && pending`, strict); else link; then **T1**: `!c || pending === NOT_PENDING || CHILDREN_FORBIDDEN || (stale && heldFromStale)` → `_value`, else `enterStagedRead; _pendingValue`. +- `read` fast block (~1739–1783): same eligibility, same **T1** verbatim. +- `read` slow tail (~1966–1995): `noCommitted && !c` → throw; `unflushedValue` arm (A28) → committed / stash + `markLateLinker`; then **T1 extended**: `+ laneReadsCommitted`, `+ (CONFIG_HELD_TRUTH && !latest && !AUTHORITATIVE)`, `+ !noCommitted` guard on the stale arm. +- `read` override arm (~1912–1938): active override, not authoritative, `!unflushedOverride` → tracked with lane/superseded → `overrideRead`, else `unwrapOverride`. +- `read` pending arm (~1821–1878): stale carve-out (`!UNINITIALIZED && !INPUTS_PUBLISHED && !laneLive && heldFromStale`) → committed; else throw / `laneSuspends`. +- `heldFromStale` (~1544–1555): foreign transaction → true, with side registration into `_gatedSubs` / `_asyncReporters`. +- `enterStagedRead` (~1578–1611): A29 entry; companion/verdict exemptions; born-held record for mainline creation. +- `unflushedValue` / `unflushedOverride` (~1640–1663): A28. +- `overrideRead` (`optimistic.ts` ~429–445): `stale && readsHeldCommitted` → `_value`; not superseded → override; stale foreign owner → override; else enter + pending/committed. +- `gatedRead` (~556–567), `laneReadsCommitted` (~573–614), `readsHeldCommitted` (`lanes.ts` ~143–154): lane-side "prefer committed" with `_gatedSubs` registration. +- `latestRead` (`verdict.ts` ~479–551), `flushedStaged` (~170–176), `computePendingState` (~259–314): verdict channels; re-derive visible override, unflushed, stale-foreign, shadow pending. + +Store twins (`store/next/store.ts`, `optimistic.ts`): `heldFoldTransition` / `foreignHold` / `heldFromReader` (≡ `heldFromStale` for backings), `readSource` + `pendingBackingVisible` (≡ T1 extended for backings, plus draft / write-override / opt-family arms), `heldTruthMasked` (≡ HELD_TRUTH arm), `visibleOverride` (≡ override arm's `unflushedOverride` gate), `nodeValue` (untracked view: override → pending → backing), `serveDataKey` (per-key: length / opt / draft overlay, then `readNodeFast`/`readNode` tracked or `nodeValue` untracked), `optimisticView` (deep compose of flushed overrides). + +**Duplicated conditions (each is a place a rule change must be threaded by hand):** T1 ×2 verbatim, T1-extended ×1 + store backing twin; stale-foreign → committed ×5; CHILDREN*FORBIDDEN → committed ×3; A28 unflushed ×6 call sites over two helpers (signal) plus `flushedStaged` (verdict) — and the store gets a \_different* answer for adopted nodes (S1); override-vs-truth ×3; HELD_TRUTH mask ×2; `enterStagedRead` on staged serve ×4. + +### Rule 2 — reporter liveness + +- Predicate: `reporterBlocksSource` (`scheduler.ts` ~1499–1542): DISPOSED → dead; ZOMBIE → walk to non-zombie parent, judge by its transaction vs verdict; boundary walk (`_collectionType & PENDING && !_initialized`) → dead (A33); `_pendingSources.has(source)` → live; deps scan through `_parentSource`/`_firewall` → live; `pending && _error.source === source` → live. Callers: `sourceObserved` → `transitionComplete`, `waitingTransition`, `enterWaiting`, `_endOptimism`, `_transitionBlocked`. +- Registration: `notify` (~897–931, INV-3), `heldFromStale`, store optimistic path. +- **Events that retire a reporter, each pushing `wokenTransitions` independently:** `disposeChildren` (`owner.ts` ~86, #3372), `recompute` tail (`core.ts` ~727, #3488), boundary reset → `wakeParked` (`boundaries.ts` ~319). Consumed in flush's `finally` on an otherwise idle pass (~892). +- **Verdict placement:** `transitionComplete` at ~774, after `runHeap(dirtyQueue)` and **before** effects; on incomplete: `stashQueues` (~805) parks the _entire_ render/user queues, `finalizePureQueue(null, true)`, return. This ordering is O3's same-flush form. + +### Rule 3/4 — deferred decisions (one structure each today) + +| Decision recorded at the pass | Carrier | Applied at commit | Dropped at park | +| ---------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------- | +| Staged value | node `_pendingValue`, `t._pendingNodes` | `commitPendingNode(s)` | kept (re-stamped) | +| Children of a held pass | `CONFIG_HELD_CHILDREN`, `_x._pendingFirstChild/_pendingDisposal`, zombie heap | `_dispose(zombie=true)` in `commitPendingNode` | zombie heap cancelled if batch===txn; immediate on re-run | +| Effect run owed | `_modified`, `_queueStash` | `restoreQueues` → `runEffect` (re-enqueues if `_valueTransition` open) | stashed whole-queue | +| Deps trim (A30) | `_depsTail`, module `heldTrims[]` | `commitPendingNodes` drains; `runEffect` trims; per-node in `commitPendingNode` | `heldTrims.length = 0` | +| Gated / stale readers to replay | `t._gatedSubs` | `finalizePureQueue` enqueue+clear | kept; merged on merge; adopted from ambient | +| Held-truth reveal | node `CONFIG_HELD_TRUTH`, module `heldRevealed[]` | post-`_resolveOptimistic` `insertSubs` | never (park never commits) | +| Cross-txn effect write | `t._contested` | `finalizePureQueue` enqueue | merged | +| Held rewrite's flushed value (A28) | `_x._flushedStaged`, module `unflushedRewrites[]` | cleared at flush start | n/a (tick-local) | + +Ambient `_batch` and a live `Transition` already share the same field set — the "cargo" concept exists; it has no lifecycle API. + +## 3. Shape + +Three moves, ordered by blast radius. Each is zero-semantic-change by construction _except_ where it closes a pinned violation, and each is gated on the matrix, both oracles, and the fuzzer baseline before and after. + +### 3a. Rule 2 — one retirement event, one verdict placement + +**`retireReporter(reporter)`** — a single entry point that the three wake sites call instead of pushing `wokenTransitions` themselves. Body is today's dedup'd push + `schedule()`, keyed on `reporter._transition`. Pure refactor; the three call sites lose their inline copies. + +**Same-flush O3.** A first reading of this case blamed effect parking (the run stashed, never proving itself dead). The probe says otherwise: the compute ran, staged `"hidden"`, and the effect is judged live only because `reporterBlocksSource`'s deps scan reads the dep A30 deliberately keeps linked past `_depsTail` for the commit to trim. Two rules, one wrong answer; the fix is in the predicate, not the scheduler: + +_Liveness reads the pass's deps, not the committed frame's._ For a reporter with a staged pass (`_pendingValue !== NOT_PENDING`), the deps scan stops at `_depsTail` — the dependencies this pass actually read. A kept tail is the committed frame's business (A30: a write to a dep the committed value still derives from must reach the node) and says nothing about whether the reporter still observes the flight. One predicate, one line, and it is the `readerLive()` consolidation's first concrete content: the predicate must know which frame it is asking about. + +Case 21 then resolves without any parking change: the pass no longer reads the memo → not live → the transaction completes at the verdict already in place, the same flush. Stash-by-world (`#3407` applied to `stashQueues`) is _not_ needed for this and should not be done on its account; it remains a separate question (§6). + +### 3b. Rule 1 — shared predicates, then one `serve` + +Not a single `serve()` first. The perf constraint is hard and measured (2026-09-15): `readNodeFast` past ~460 B of bytecode, or a call on its staged branch, costs 10–15% on propagation; `setSignal` past the inline budget costs 10–20% on the write loop. The fast path must stay a tiny inlinable guard that handles the trivial case and bails. Value selection therefore has exactly **two** implementations by design — the fast ternary (T1) and one slow `serve` — and the target is to make the third-through-eleventh disappear, not the second. + +Step 1 — **shared predicates**, no behavior change: `readerSeesCommitted(el, c)` (= T1-extended's disjunction, including HELD*TRUTH and lane arms), `visibleOverride(el)` (already exists store-side; core's override arm inlines the same test), `unflushed(el)` with **one** definition used by `unflushedValue`, `flushedStaged`, `pendingBackingVisible` and `nodeValue`, and `readerClass(ctx)` — the three ways a reader relates to a hold, today spread over three unrelated flags: **derives** (a tracked pass: joins, or is born held), **displays** (a render/user effect's apply: sees the committed frame now, replays at the reveal — `_gatedSubs`), **observes** (verdict pulls `_verdictPull`, companions `_parentSource`: mirrors of the flushed world, never join). Direct-commit readers (`CONFIG_DIRECT_COMMIT` — `resolve()`/`until()`) are **not** observers: they are derivers with a tunnel \_inside their own transaction* (the arm that lets a hold not deadlock on its own acknowledgment), and from mainline over a **foreign** hold they wait for the commit like any deriver — #3492 pins that a mainline `resolve()` must never resolve with an unrelated action's unrevealed frame, which is exactly what exempting them from born-held (#3490) leaked. `enterStagedRead`, `heldFromStale` and `recompute`'s commit arm each test a different subset of these flags today. This step closes **S1**: "unflushed" = staged outside a flush and not yet carried by one, whatever the stamp — one predicate, so the signal and the store cannot disagree. Mechanism: `queuePendingNode` outside a flush already sets `unflushedStaged`; a per-node bit set there and cleared by the carrying flush (`resyncUnflushedCompanions` walks the batch's pending nodes — it is already the flush-start hook) makes adoption irrelevant to the test. + +Step 2 — **`serve(el, reader)`** as the slow tail: `read`'s slow arms, `overrideRead`, `latestRead`'s value selection and the store's `nodeValue`/`readSource` value decision call it; the store keeps its structural arms (draft overlay, length, chained, opt family) and delegates the _value_ decision. `gatedRead`/`laneReadsCommitted`/`readsHeldCommitted` fold into `readerSeesCommitted` with their `_gatedSubs` registration as a side effect of the predicate, as `heldFromStale` already does. + +Bytes: expect roughly neutral to slightly positive. Three mechanism-preserving consolidations this month came back +13…+85 B; the pitch is one site per rule, not size. + +### 3c. Rule 3/4 — cargo lifecycle + +Give the shared batch/transaction field set the two functions it lacks: `applyCargo(t)` (today's `commitPendingNodes` + `_gatedSubs` replay + `heldRevealed` wake + `heldTrims` drain + zombie dispose, in the order `finalizePureQueue` runs them) and `dropCargo(t)` (today's park path: `heldTrims.length = 0`, zombie cancel, `stashQueues`). `heldTrims` and `heldRevealed` move from module arrays onto the transaction they belong to (a module array is only correct while one transaction commits at a time, which `finalizePureQueue` guarantees today — by accident of sequencing, not by construction). New deferrals then have exactly one place to go. + +This is the largest move and the one with the least direct violation behind it; it can wait for the first new "decided at the pass" fix to motivate it, or be done when 3a/3b have settled. + +## 4. Verification protocol (per PR) + +1. `tests/visibility-oracle.test.ts`, `-store.test.ts`: every cell unchanged. +2. `tests/visibility-oracle-posture.test.ts`: 621-cell report diffed against the pre-change report; the only permitted diffs are the cells a pinned violation says should flip. +3. Fuzzer (#3446) campaign, same seed: baseline 984 / 4 / 12; permitted change is the pinned violation's cases. +4. CodSpeed on the PR; write-loop benches (`update1to1`, `update1to1000`, `diamond`, `avoidable`) alternating pairs; `--print-bytecode` for `readNodeFast`, `read`, `setSignal`, `recompute` before/after. +5. Size: floor and the nine brotli scenarios; report the delta, do not sell it. + +## 5. Sequencing + +1. **3a** — `retireReporter` + the deps-scan bounded by `_depsTail` for staged passes. Closes O3's same-flush form (fuzzer 4 → 0 expected). Smallest blast radius; touches `reporterBlocksSource` and three wake sites. +2. **3b step 1** — shared predicates incl. one `unflushed`. Closes S1. Touches `core.ts` read arms, `verdict.ts`, store `store.ts`; no fast-path change. +3. **O2 ruling**, then whichever answer, applied once via `enterStagedRead` (born held everywhere: the `creatingPass` prototype, +83 B) or via `recompute`'s create arm (escapes everywhere: retire the mainline born-held form). +4. **3b step 2** — `serve`. +5. **3c** — cargo lifecycle, when motivated. + +## 6. Open questions for the maintainer + +- **Stash-by-world (not required for O3):** `stashQueues` parks the whole render/user queue when a transaction parks, including effects dirtied only by a mainline write in that round. #3407 read literally says those belong to mainline and should run. Not a violation anyone has pinned; flagged as a candidate rule to make explicit, not a change to make now. +- **O2:** born held everywhere, or escapes everywhere. Either is consistent; the current state (mainline held, transaction/boundary creation escapes) is the only inconsistent option. **Insight from #3482 (2026-09-16):** born-held bundles two decisions that should be separate — _ownership_ (the created value belongs to the transaction it derived from) and _application_ (skip the effect's first run, replay at commit). The ownership half was right even in #3482's misuse: the post-`await` `until()` _was_ the action's reader, and born-held correctly made it the action's. What deadlocked was a reader created in the wrong posture (post-`await`, mainline by mechanism) over its _own_ action's hold — and from mainline, waiting for the commit is the correct behavior for that reader class (#3492: a mainline `resolve()` over a foreign hold must not see the held frame; the direct-commit tunnel is only for a reader inside its own transaction). So born-held was right on both halves there; the misuse is what put the reader in a posture where "right" deadlocks, and the docs/lint are the fix. "Born held everywhere" remains the recommendation for O2's actual question — creation _under_ a transaction / in boundary content — ownership and application both following derivation, as mainline creation already does (the `creatingPass` prototype, +83 B). The `CONFIG_DIRECT_COMMIT` exemption proposed in #3482 is declined on the evidence, not on taste. A further argument for posture-independence: the posture is exactly what users get wrong (`await` vs `yield`), so a rule that changes with the posture turns a documentation slip into a semantic one. +- **`readsHeldCommitted` and the lane arms:** folding them into `readerSeesCommitted` assumes lanes are "a transaction with an override"; if lanes are meant to diverge from transactions later, keep them as a separate predicate that `serve` consults. +- **Post-`await` posture:** pinned by #3492 for the direct-commit readers (three postures: own step, own `await` continuation, foreign mainline) as a standalone file. Worth folding into the matrix as a posture (`ownActionAfterAwait`) so the other reader kinds get the same rows; not urgent. +- **Loosening:** once `serve` exists, each of its arms is a constraint with a measurable blast radius (flip it, rerun the matrix). Candidates surfaced so far: O2 (two born-held forms → one), the `CONFIG_HELD_TRUTH` mask (one arm, two sites), and the stale-foreign carve-out in the pending arm (`INPUTS_PUBLISHED`), which exists to serve one shape (#3305). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d9735bdd1..01cd0508c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -61,7 +61,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | | A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×5 lanes.ts×2 scheduler.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.states.ts×5 visibility-oracle.states.ts×6 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | 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… | +| 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 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×11 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×25 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×3 constants.ts×1 core.ts×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… | @@ -72,8 +72,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:233` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| 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×4 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 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | 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 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index c940dcd6d..9d6f0f5e5 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1602,16 +1602,18 @@ function heldFromStale(el: Signal | Computed, c: Computed): boole let stagedEntry: Transition | null = null; export function enterStagedRead( - el: Signal | Computed, - t: Transition | null | undefined = el._transition + el: Signal | Computed | null, + t: Transition | null | undefined = el!._transition ): void { if (!t || t === activeTransition || pendingCheckActive) return; // A companion (the latest() shadow, the isPending() verdict signal) is the // engine's mirror of the flushed world — reading it, or being it, is an // observation, not a derivation from the hold: latest(x) never enters x's // transaction, and the shadow's own pass never enters either (it would - // flip activeTransition under the reader that pulled it). - if (el._x?._parentSource || (context as Computed | null)?._x?._parentSource) return; + // flip activeTransition under the reader that pulled it). (`el` is null for + // a store backing served under a hold — no node, the transaction is the + // fold's.) + if (el?._x?._parentSource || (context as Computed | null)?._x?._parentSource) 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 @@ -1641,7 +1643,7 @@ export function enterStagedRead( * 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: + * verdict arms — used to restate by hand (docs/DESIGN-CONSOLIDATION.md, 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); @@ -1746,7 +1748,7 @@ export function hasActiveOverride(el: Signal | Computed): boolean { * 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). */ + * (docs/DESIGN-CONSOLIDATION.md, move 3b). */ export function visibleOverride(el: Signal | Computed): boolean { return hasActiveOverride(el) && !unflushedOverride(el); } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 68118d3bd..bc39e3e90 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -40,6 +40,7 @@ import { visibleOverride, readerSeesCommitted, recordStaleReplay, + enterStagedRead, prepareComputed, read as readNode, READ_SLOW, @@ -424,6 +425,17 @@ function staleReplay(txn: Transition): void { if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c); } +/** The other half of serving a held backing: a deriving reader (a pass in + * owner context) served the pending backing a live transaction holds + * derives from that transaction's world and enters it (A29, core + * enterStagedRead on the same arm) — its result is held with the fold, not + * published into the mainline frame. Without it a mainline memo's untracked + * read of a held key (`untrack(() => s.n)`, `deep(s)`) published the + * unrevealed value while the same read of a signal was born held. */ +function enterHeldBacking(target: StoreNextTarget, txn = liveFoldTransition(target)): void { + if (txn !== null && readerContext() !== null) enterStagedRead(null, txn); +} + /** Core read()'s `activeTransition !== el._transition`: a hold belongs to a * FOREIGN transaction unless the flush running now is that transaction's — * its own stale readers (a render effect recomputing in it, whose run the @@ -1688,6 +1700,10 @@ function readSource(target: StoreNextTarget): Record { if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition)); return hv; } + } else if (target.ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) { + // An owner-context deriving reader served the ADOPTED view under a live + // adoption hold derives from the adoption's transaction (A29). + enterHeldBacking(target, heldAdoptionTransition(target)); } return pendingBackingVisible(target, false) ? target.pb! : target.v; } @@ -1721,7 +1737,10 @@ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): b // ordinary readers keep committed until the transaction's reveal). // Stale readers and owner-less peeks of a TRANSACTION-held backing see // committed, as core read() serves them (#3336, heldFromReader). - ((speculative || inOwnerContext()) && !heldTruthMasked(target) && !heldFromReader(target)) || + ((speculative || inOwnerContext()) && + !heldTruthMasked(target) && + !heldFromReader(target) && + (enterHeldBacking(target), true)) || // A projection's pending backing is authoritative-elect: serve it to // context-free readers too UNLESS a transition is holding the node // commits (downstream async hold — stale committed is the contract) @@ -1830,7 +1849,12 @@ function nodeValue(node: Signal, backing: any): any { // parent computed). (latestReadActive || authoritativeServe() || - !readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false)) + // A deriving reader served the staged value enters its + // transaction (A29) as core read() does on the same arm: the + // pass is the hold's, its result held with it — an untracked read + // inside a mainline memo must not publish the unrevealed frame. + (!readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false) && + (enterStagedRead(node), true))) ? 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 bb5bf920b..88c08bdd2 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -19,6 +19,16 @@ * S3 — INV-4 after disposing a projection mid-refetch: its own file, * tests/inv4-projection-dispose-shadow.test.ts (the live actions S1/S2 * leave behind would mask the quiescence check here). Spec O5. + * S4 — (fixed, 3b steps 2–3) a stale reader's UNTRACKED read of a held store + * key replays at the commit as the signal's does — node, backing, and + * adoption-hold paths (recordStaleReplay). + * S5 — (fixed, 3b step 4) a mainline derivation's UNTRACKED read of a held + * store key is born held (A29) as the signal's is — the store's untracked + * paths served the pending value without entering the transaction. + * S6, S7 — DIVERGENCES recorded at their current values, not ruled (see the + * block comment above them). + * Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked` + * reader kinds (an untracked read inside a derivation), added with S5. * * (A first cut also reported the projection's seed leaking as a value inside * boundary content, and `isPending` false / override invisible behind a @@ -29,8 +39,11 @@ import { describe, expect, it } from "vitest"; import { action, + createEffect, createLoadingBoundary, createMemo, + createOptimistic, + createOptimisticStore, createRenderEffect, createRoot, createSignal, @@ -260,3 +273,179 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th }); } }); + +/** A deriving reader (memo, user effect) created MAINLINE whose UNTRACKED + * read is of a value held by a live action: the pass is served the staged + * value and enters the transaction — born held (A29) — so nothing is + * published until the action commits. The signal did this (core read() + * enters on the same arm that serves the staged value; `context` persists + * under untrack). The store's untracked paths (nodeValue, the backing's + * pendingBackingVisible, the adoption-hold view) served the pending value + * WITHOUT entering: a mainline memo published the action's unrevealed write + * to the screen while the same read of a signal was held. */ +type HeldShape = "signal" | "store+node" | "store" | "store reconcile" | "store reconcile+node"; +function heldShape(shape: HeldShape) { + if (shape === "signal") { + const [x, setX] = createSignal(0); + return { read: x, write: () => setX(1) }; + } + const [s, setS] = createStore({ n: 0 }); + if (shape.endsWith("+node")) { + createRoot(() => + createRenderEffect( + () => s.n, + () => {} + ) + ); + flush(); + } + return { + read: () => s.n, + write: () => + shape.startsWith("store reconcile") + ? setS(reconcile({ n: 1 })) + : setS(d => { + d.n = 1; + }) + }; +} +describe("S5 — a mainline derivation's UNTRACKED read of a held value is born held (A29) — signal vs store", () => { + for (const shape of [ + "signal", + "store+node", + "store", + "store reconcile", + "store reconcile+node" + ] as HeldShape[]) { + it(`${shape}: memo → render effect publishes nothing until the action commits`, async () => { + const { read, write } = heldShape(shape); + let release!: () => void; + action(function* () { + write(); + yield new Promise(res => (release = res)); + })(); + flush(); + const log: number[] = []; + const [u, setU] = createSignal(0); + createRoot(() => { + const m = createMemo(() => { + u(); + return untrack(read); + }); + createRenderEffect(m, v => { + log.push(v); + }); + }); + flush(); + setU(1); // a re-run off the hold is held too + flush(); + expect(log).toEqual([]); + release(); + await settle(); + expect(log).toEqual([1]); + }); + it(`${shape}: user effect runs once, after the commit`, async () => { + const { read, write } = heldShape(shape); + let release!: () => void; + action(function* () { + write(); + yield new Promise(res => (release = res)); + })(); + flush(); + const log: number[] = []; + createRoot(() => { + createEffect( + () => untrack(read), + v => { + log.push(v); + } + ); + }); + flush(); + expect(log).toEqual([]); + release(); + await settle(); + expect(log).toEqual([1]); + }); + } +}); + +/** DIVERGENCES the 7-reader matrix shows and this file only RECORDS (both + * sides pinned at their current value; not ruled — flip the store or the + * signal when the maintainer rules): + * + * S6 — staged, ambient (a write before any flush), reader created INSIDE a + * foreign action (which adopts the write, spec O1): the signal's memo → + * render effect publishes the committed 0 (A28 / #3510: adopted before + * any flush = unflushed, served committed); the store's publishes the + * pending 1 (pendingBackingVisible: owner context → pending backing). + * The verdict channels already agree (S1); the derivation reads do not. + * S7 — optimistic store, override active, the only reader gated away: the + * signal's x() still reads the override 5 while the action is live + * (A17); the store's s.n reads 0 — the override is invisible to an + * untracked read once no reader observes the key. + */ +describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivation created inside a foreign action", () => { + function publishedInsideForeignAction(read: () => number) { + const log: number[] = []; + action(function* () { + createRoot(() => { + const m = createMemo(read); + createRenderEffect(m, v => { + log.push(v); + }); + }); + yield never(); + })(); + flush(); + return log; + } + it("signal: publishes the committed 0", () => { + const [x, setX] = createSignal(0); + setX(1); + expect(publishedInsideForeignAction(x)).toEqual([0]); + }); + it("store: publishes the pending 1", () => { + const [s, setS] = createStore({ n: 0 }); + setS(d => { + d.n = 1; + }); + expect(publishedInsideForeignAction(() => s.n)).toEqual([1]); + }); +}); + +describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gated away", () => { + function gateAway(read: () => number) { + const [show, setShow] = createSignal(true); + createRoot(() => { + createRenderEffect( + () => (show() ? read() : "gated"), + () => {} + ); + }); + flush(); + setShow(false); + flush(); + return read(); + } + it("signal: x() still reads the override while the action is live", () => { + const [x, setX] = createOptimistic(0); + action(function* () { + setX(5); + yield never(); + })(); + flush(); + expect(gateAway(x)).toBe(5); + }); + it("store: s.n reads the committed 0 once nothing observes the key", () => { + const [s, setS] = createOptimisticStore({ n: 0 }); + action(function* () { + setS(d => { + d.n = 5; + }); + yield never(); + })(); + flush(); + expect(gateAway(() => s.n)).toBe(0); + }); +}); diff --git a/packages/signals/tests/visibility-oracle-posture.test.ts b/packages/signals/tests/visibility-oracle-posture.test.ts index 505da8d22..e7e5ef258 100644 --- a/packages/signals/tests/visibility-oracle-posture.test.ts +++ b/packages/signals/tests/visibility-oracle-posture.test.ts @@ -48,7 +48,8 @@ import { flush, isPending, latest, - NotReadyError + NotReadyError, + untrack } from "../src/index.js"; import { HELD, @@ -99,7 +100,20 @@ const POSTURES = [ "gatedAway" ] as const; type Posture = (typeof POSTURES)[number]; -const READERS = ["untracked", "memo", "effect", "latest", "isPending"] as const; +// memoUntracked / effectUntracked: the derivation reads x() under untrack() — +// still that pass's read (context persists under untrack), so the value +// selection is the reader's, without a subscription. The kind that found the +// store's untracked paths publishing a held frame a signal is born held on +// (posture-store-parity S5) and skipping the stale reader's replay (S4). +const READERS = [ + "untracked", + "memo", + "memoUntracked", + "effect", + "effectUntracked", + "latest", + "isPending" +] as const; type Reader = (typeof READERS)[number]; const classify = (fn: () => unknown): Cell => { @@ -211,6 +225,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise untrack(x) : x; const { y } = enter(posture, () => { switch (reader) { case "untracked": @@ -222,13 +237,14 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise isPending(x)); break; - case "memo": { + case "memo": + case "memoUntracked": { const d = createRoot(d => { const m = createMemo(() => { if (gated && !show()) return "gated"; let v: Cell; try { - v = x(); + v = rd(); } catch (e) { pass.push( e instanceof NotReadyError @@ -248,14 +264,15 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise { createRenderEffect( () => { if (gated && !show()) return "gated"; let v: Cell; try { - v = x(); + v = rd(); } catch (e) { pass.push( e instanceof NotReadyError @@ -282,7 +299,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise