From 95b153e62dca27850e3bc013e742eead468c77f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:11:12 +0000 Subject: [PATCH] fix(signals): an inherited verdict posture reads the hold's own write (#4544) A pass whose verdict posture is only inherited (it read a memo that calls isPending/latest, and neither it nor its node's last pass opened a window of its own) is the hold's work where it reads the hold's own sync write: it is served that staging and joins the hold. The branch a hold opens then reads the hold's world and the hold lands, in one flush() or several (F-10). Before the flush parks, such a read leaves the verdict lane an earlier read of the verdict memo seated the pass in. A staging a window asked of under the hold that holds it (or in this flush) still reads as the screen, so `[latest(x), x()]` through a memo stays atomic (A10, D-26). The mark (`_askedIn`) names that hold and expires with it: nothing sticky. CONFIG_OWN_PROBE is renewed by every pass from REACTIVE_WINDOW. A pass that probes in its own body stays the hold's verdict reader (F-6). When a render effect born into the hold reads such a reader's settled value and goes pending on what that display-ahead value asks for, the pending no longer holds the hold (CONFIG_AHEAD, F-6): the hold lands and the reader re-derives at the landing (A15). A lane's reveal still waits (S-29). Everything is gated on the verdict engine's hooks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1 --- .changeset/fix-4544-own-staging.md | 5 + packages/signals/docs/RULES-DERIVED.md | 2 +- packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/scripts/mangle-props.json | 2 + packages/signals/src/core/constants.ts | 14 + packages/signals/src/core/core.ts | 34 ++- packages/signals/src/core/scheduler.ts | 23 +- packages/signals/src/core/types.ts | 4 + packages/signals/src/core/verdict.ts | 39 ++- .../verdict-memo-held-branch-4544.test.ts | 257 ++++++++++++++++++ .../verdict-memo-held-branch-4544.spec.tsx | 114 ++++++++ 11 files changed, 490 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-4544-own-staging.md create mode 100644 packages/signals/tests/verdict-memo-held-branch-4544.test.ts create mode 100644 packages/web/test/verdict-memo-held-branch-4544.spec.tsx diff --git a/.changeset/fix-4544-own-staging.md b/.changeset/fix-4544-own-staging.md new file mode 100644 index 0000000000..24f61ad43b --- /dev/null +++ b/.changeset/fix-4544-own-staging.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A memo whose verdict posture is only inherited (it reads a memo that calls `isPending`/`latest`, and probes nothing itself) is the hold's work where it reads the hold's own write: it reads that staging instead of the pre-hold value, so a branch the hold opens no longer reads the pre-hold world and the hold lands, whether the writes go through one `flush()` or several. A memo that probes in its own body still reads the screen, and what a branch born into the hold then asks for no longer keeps the hold from landing (#4544). diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 6413e461c2..a4935cefbd 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -206,7 +206,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 What is rendered from a display-ahead read reveals together with it: an async memo over `latest(x)` holds the `latest` view's reveal until it lands; a combined `isPending(() => [fast(), copy()])` over two async memos, one wrapped in a sync memo, stays `true` until both land, because a memo's value is one shared slot — it computes under its own hold posture, never under the posture of whichever reader pulled it. A node whose fetch a hold is waiting on is that hold's work, not a verdict reader: a pass that re-runs it while its fetch is in flight reads plainly — following the change it is fetching for — even if an earlier pass of it called `isPending` or `latest` (#3884). A memo's posture is what it reads: one that reads a verdict reader's value is a derivation of a display-ahead read, and a verdict reader itself — `createMemo(() => [held(), V()])` with `V = createMemo(() => isPending(x) && latest(e))` shows `V`'s verdict now, beside the screen's `held()`, as the inline probe and a render effect reading both do (#4050: a Provider's children memo over a router shell). **The probe decides the pass's posture in either read order (clarified 2026-10-28):** a pass that probes is a verdict reader whether the probe came before or after a plain read of the same held node — `[x(), isPending(x)]` and `[isPending(x), x()]` both show `[committed, true]` now and re-derive at the landing (A10) — unless the pass is the hold's own work: born in its world (D-27, #4341) or re-run by the write it holds (#3884 above). Those two read plainly; a probe does not rescue them. A pass that read two holds' display-ahead values is one reveal unit of both (A15 lanes corollary, #3335): it waits for either lane's flight, never showing one hold's `latest()` beside a half-computed derivation of the other's. -**A verdict posture outlives the pass that asked (ruled 2026-10-28):** a pass that asked (`isPending`, `latest`) is the hold's verdict reader on every later run of its node until one completes having asked nothing — that run still reads as a verdict reader; the next is an ordinary derivation. On each such run its plain read of a node the hold holds is the screen's (a frame reader, A10; re-derived at the landing as A15's stale reader) and it joins nothing, whoever dirtied it — a sync write, a boundary redraw, a landing. The hold's own work is not in this: a node whose fetch the hold waits on, or a pass born in its world, reads plainly whatever it asked before (the #3884 clause and D-27 #4341 above, unchanged). A boundary's display-ahead swap is this posture handed on: an `on` pass that read `latest`/`isPending` is a verdict reader, and the fallback swap it asks for runs as that reader's work — the output's pass reads nothing of the held frame (a pending tree is linked, not read) and its answer, the fallback, is shown now beside the held frame and stays the screen until the landing, instead of being staged with the write's frame as a plain `on`'s swap is (D-15): `[A] → [A + spinner] → [B + spinner] → [B + comments]`, never `[A] → [B + spinner]`. Only the `on` pass's asking counts: `latest()` inside the fallback is the fallback's own display-ahead, not the swap's — a plain `on` over an action-held input lands the swap with the frame whatever the fallback reads (#4337). Today two memories carry this — `CONFIG_VERDICT` left on the node by the pass that probed, and the node's listing in the hold's verdict lane (`fallback`'s `b._lane`, `recompute`'s head) — and the rule is the posture, not the membership. Pins: `tests/loading-on-rearm-3540.test.ts`, `tests/loading-on-frame-following-3540.test.ts` §4–5, `tests/loading-on-latest-fallback-4337.test.ts`. +**A verdict posture outlives the pass that asked (ruled 2026-10-28):** a pass that asked (`isPending`, `latest`) is the hold's verdict reader on every later run of its node until one completes having asked nothing — that run still reads as a verdict reader; the next is an ordinary derivation. On each such run its plain read of a node the hold holds is the screen's (a frame reader, A10; re-derived at the landing as A15's stale reader) and it joins nothing, whoever dirtied it — a sync write, a boundary redraw, a landing. The hold's own work is not in this: a node whose fetch the hold waits on, or a pass born in its world, reads plainly whatever it asked before (the #3884 clause and D-27 #4341 above, unchanged). A boundary's display-ahead swap is this posture handed on: an `on` pass that read `latest`/`isPending` is a verdict reader, and the fallback swap it asks for runs as that reader's work — the output's pass reads nothing of the held frame (a pending tree is linked, not read) and its answer, the fallback, is shown now beside the held frame and stays the screen until the landing, instead of being staged with the write's frame as a plain `on`'s swap is (D-15): `[A] → [A + spinner] → [B + spinner] → [B + comments]`, never `[A] → [B + spinner]`. Only the `on` pass's asking counts: `latest()` inside the fallback is the fallback's own display-ahead, not the swap's — a plain `on` over an action-held input lands the swap with the frame whatever the fallback reads (#4337). Today two memories carry this — `CONFIG_VERDICT` left on the node by the pass that probed, and the node's listing in the hold's verdict lane (`fallback`'s `b._lane`, `recompute`'s head) — and the rule is the posture, not the membership. Pins: `tests/loading-on-rearm-3540.test.ts`, `tests/loading-on-frame-following-3540.test.ts` §4–5, `tests/loading-on-latest-fallback-4337.test.ts`. **An inherited posture is not a probe (2026-11-02, #4544):** a pass whose verdict posture is only inherited — it read a verdict memo, and neither it nor its node's last pass opened a window of its own — is the hold's work where it reads the hold's own write (a sync write the hold holds, not a flight's answer): it is served that staging and joins the hold, as a plain derivation does. One flush or several make no difference (F-10): before the flush parks, the pass reads the flush's own write as that staging, leaving the verdict lane an earlier read of the verdict memo seated it in, as the other read order keeps it out. Not a staging a window asked of under the hold that holds it (or in this flush, before one): `[latest(x), x()]` through a memo stays atomic (A10, D-26: `tests/latest-memo-inline-parity.test.ts`). That mark is the hold's — it expires when the hold lands or dissolves, and a later hold reads the node as never asked. A pass that probes in its own body stays the hold's verdict reader and reads the screen (F-6 over a staging it holds). When a frame born into the hold (a render effect never committed) reads such a reader's settled value and goes pending on what that display-ahead value asks for, the pending does not hold the hold (F-6: display-ahead readers never hold the change they read ahead of): the hold lands, the reader re-derives at the landing (A15) and the frame with it. A lane's reveal still waits on it (S-29). Pins: `tests/verdict-memo-held-branch-4544.test.ts`. **Collapses:** [A31](./SPEC-ASYNC-SEMANTICS.md#a31-a-memo-computes-under-its-own-lane-posture-never-its-pullers) (#3442), §20.1 question 3 ("Yeah definitely it is held. Consistent with any optimistic update"), OL-R24/R25/R34, recovery 6.2. **Status:** standing. A memo over a verdict memo: `tests/verdict-memo-under-held-memo-4050.test.ts`, `packages/web/test/held-shell-pending-feedback-4050.spec.tsx` (U-10). **Fixed 2026-11-01:** the verdict shortcut in `propagateStatus` (a probed source settling re-derives its verdict reader) now clears the settled source from the reader's pending sources — left in place, an errored probe target stranded a reader re-parked on a second source at that source's landing; `tests/verdict-shortcut-pending-source-liveness.test.ts`. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d81cb11550..d8be818a3e 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -54,12 +54,12 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | seat.ts×1 truth.ts×1 verdict.ts×1 | fuzz-findings-l2.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:175` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:183` | async.ts×1 projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×1 seat.ts×1 verdict.ts×3 | boundary-not-born-held-3540.test.ts×1 createMemo.test.ts×1 error-staged-recovery.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 latest-uninitialized-over-latest-4362.test.ts×1 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×1 seat.ts×1 verdict.ts×4 | boundary-not-born-held-3540.test.ts×1 createMemo.test.ts×1 error-staged-recovery.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 latest-uninitialized-over-latest-4362.test.ts×1 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | | A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | — | spec-async-semantics.test.ts×1 | [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:251` | async.ts×2 constants.ts×1 core.ts×4 effect.ts×1 lanes.ts×6 scheduler.ts×16 seat.ts×1 verdict.ts×4 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 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 show-stale-child-in-action-4137.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 tracked-effect-staged-dep-4606-4607.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-shortcut-pending-source-liveness.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×2 constants.ts×1 core.ts×4 effect.ts×1 lanes.ts×6 scheduler.ts×16 seat.ts×1 verdict.ts×4 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 latest-memo-inline-parity.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 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 show-stale-child-in-action-4137.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 tracked-effect-staged-dep-4606-4607.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-memo-held-branch-4544.test.ts×1 verdict-shortcut-pending-source-liveness.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.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:223` | scheduler.ts×1 | caught-reactive-read-diagnostic.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 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:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 seat.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.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×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 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:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 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… | @@ -75,7 +75,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:95` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 seat.ts×3 types.ts×1 verdict.ts×2 store.ts×5 | continuation-sibling-reader-4146.test.ts×1 createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 issue-3800-repro.test.ts×1 latest-held-till-flush.test.ts×1 latest-memo-inline-parity.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 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:119` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×14 effect.ts×1 lanes.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fallback-born-creator-4428.test.ts×1 fuzz-findings-l2.test.ts×6 held-branch-verdict-reader-4010.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-frame-verdict-parity.test.ts×1 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 verdict-born-into-other-flush-4426.test.ts×1 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.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:259` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 projection.ts×1 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 projection-peek-loop-4342.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [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:133` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 latest-pulled-pass-ispending.test.ts×1 probe-pulled-pass-4429.test.ts×1 unowned-memo-ispending-reader-4366.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:133` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 latest-pulled-pass-ispending.test.ts×1 probe-pulled-pass-4429.test.ts×1 unowned-memo-ispending-reader-4366.test.ts×1 verdict-memo-held-branch-4544.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:141` | core.ts×4 lanes.ts×1 seat.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:275` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×4 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | | A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:285` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×7 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-guess-over-held-slot.test.ts×2 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | diff --git a/packages/signals/scripts/mangle-props.json b/packages/signals/scripts/mangle-props.json index c3744ceef5..6ac03277e0 100644 --- a/packages/signals/scripts/mangle-props.json +++ b/packages/signals/scripts/mangle-props.json @@ -4,6 +4,7 @@ "_armLane": "B", "_armed": "h", "_asked": "dn", + "_askedIn": "Nt", "_blocked": "fe", "_born": "ro", "_boundRunEffect": "W", @@ -150,6 +151,7 @@ "_armLane": "B", "_armed": "h", "_asked": "dn", + "_askedIn": "In", "_blocked": "fe", "_born": "ro", "_boundRunEffect": "W", diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 5eb8d994af..1de5d8ff06 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -73,6 +73,10 @@ export const REACTIVE_PROBE_UNANSWERED = 1 << 19; * did not is D-25's hold work while its flight is up (verdict.ts * `holdWork`). Per pass. */ export const REACTIVE_WINDOW = 1 << 21; +/** #4544: a render effect born into a hold read a settled verdict reader's + * value this pass (core.ts `read`). Per pass: `recompute`'s tail keeps it as + * CONFIG_AHEAD. */ +export const REACTIVE_AHEAD = 1 << 20; /** An observation-lifecycle node found unobserved by a settle (#2934) while * a pass of it was already queued — a source's landing re-enqueued the * `_blocked` reader it walks (`settlePendingSource`). The release waits for @@ -231,6 +235,16 @@ export const CONFIG_FRAME = 1 << 23; /** The node's last answering pass registered a flight: an async memo. Kept * by the verdict engine alone (verdict.ts `holdWork`), which reads it. */ export const CONFIG_ASYNC = 1 << 29; +/** #4544: the node's last pass opened a verdict window of its own + * (`isPending`/`latest`) — REACTIVE_WINDOW, kept by `recompute`'s tail + * beside CONFIG_VERDICT and renewed by every pass. One whose posture is only + * inherited (it read a verdict memo) is the hold's work where it reads the + * hold's own write (verdict.ts `holdStaging`). */ +export const CONFIG_OWN_PROBE = REACTIVE_WINDOW << 9; +/** #4544 (F-6): the effect's last pass read a display-ahead value + * (REACTIVE_AHEAD): what it went pending on does not hold its hold + * (scheduler.ts `blockedBy`). The sign bit: tested by `&` only. */ +export const CONFIG_AHEAD = REACTIVE_AHEAD << 11; export const STATUS_NONE = 0; export const STATUS_PENDING = 1 << 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 2ce4ccb604..b640ebe785 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -59,6 +59,10 @@ import { REACTIVE_MANUAL_WRITE, REACTIVE_MISSED_WAKE, REACTIVE_PROBED, + REACTIVE_WINDOW, + REACTIVE_AHEAD, + CONFIG_OWN_PROBE, + CONFIG_AHEAD, REACTIVE_REASK, REACTIVE_STAGED_READ, REACTIVE_NONE, @@ -167,6 +171,7 @@ import { laneStageHook, laneSeamsHook, laneServedHook, + holdStagingHook, holdWorkHook, keepReadersHook, laneWriteHook, @@ -732,7 +737,11 @@ export function recompute(el: Computed, create: boolean = false): void { // verdict engine's to keep (CONFIG_ASYNC, verdict.ts `holdWork`, D-25). // (Only the verdict engine probes: without it the line folds away.) if (observeFlightHook) - el._config = (el._config & ~CONFIG_VERDICT) | (el._flags & REACTIVE_PROBED); + el._config = + (el._config & ~(CONFIG_VERDICT | CONFIG_OWN_PROBE | CONFIG_AHEAD)) | + (el._flags & REACTIVE_PROBED) | + ((el._flags & REACTIVE_WINDOW) << 9) | + ((el._flags & REACTIVE_AHEAD) << 11); // REACTIVE_DISPOSED survives too (#3621): the pass may have disposed its // own owner (a memo calling its root's `dispose()`, a cleanup doing so // #3601/#3606), and `disposeChildren` set the flag on this node @@ -1345,7 +1354,10 @@ function frameRead(c: Computed, el: Signal | Computed): boolean { // under a hold still enters it: the content memo it reads is born held, // with no committed value to serve. // (Verdict readers and lanes exist only with their engines installed.) + // #4544: nor a pass whose posture is only inherited, reading the hold's + // own write (verdict.ts `holdStaging`): it is the hold's work. const verdict = observeFlightHook && c._config & CONFIG_VERDICT; + if (verdict && holdStagingHook!(c, el)) return false; const lane = laneStageHook && passLane !== null; if ( (!lane || @@ -2198,6 +2210,21 @@ export function read(el: Signal | Computed): T { // verdict reader keeps the posture it is served the screen with // (`observeFlight`). (A verdict memo exists only with the verdict // engine installed.) + // #4544 (F-6): a render effect never committed (uninitialized — held + // at the park if this flush parks — or of a held pass's frame; + // `blockedBy` asks only of one its hold holds) reading a settled verdict + // reader's value derives from a display-ahead read: what that value + // asks for does not hold the hold (scheduler.ts `blockedBy`). + if ( + observeFlightHook && + (c as any)._type === EFFECT_RENDER && + owner._config & CONFIG_VERDICT && + !(owner._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) && + ((c as Computed)._statusFlags & STATUS_UNINITIALIZED + ? c._config & CONFIG_HELD || flushTransaction + : c._config & CONFIG_HELD_FRAME) + ) + (c as Computed)._flags |= REACTIVE_AHEAD; if (observeFlightHook && !(c as any)._type) (c as Computed)._flags |= owner._config & @@ -2529,7 +2556,10 @@ export function stagedScreen(c: Computed, el?: Signal | Computed) if ( flushTransaction === null || bornInto(c, flushTransaction) || - (el?._config! & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD + (el?._config! & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD || + // #4544: this flush's own write, read by a pass whose posture is only + // inherited, is the frame's derivation, not a probe of it. + (observeFlightHook && holdStagingHook!(c, el!)) ) return false; // A transaction only a `latest()` read opened holds nothing: the staging diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 8f499f1983..6cac72942c 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -1,4 +1,5 @@ import { + CONFIG_AHEAD, CONFIG_FRAME, CONFIG_HELD, CONFIG_HELD_FRAME, @@ -189,18 +190,24 @@ export let stageMarkHook: ((n: Computed, el: Computed) => void) | null /** #4417 (verdict.ts `keepReaders`); #4574 (verdict.ts `attachWaits`). */ export let keepReadersHook: ((el: Computed) => void) | null = null; export let attachHook: ((node: Computed) => boolean) | null = null; +/** #4544 (verdict.ts). Null without the verdict engine. */ +export let holdStagingHook: + | ((c: Computed, el: Signal | Computed) => unknown) + | null = null; export function installVerdictHooks( observeFlight: NonNullable, holdWork: NonNullable, stageMark: NonNullable, keepReaders: NonNullable, - attach: NonNullable + attach: NonNullable, + holdStaging: NonNullable ): void { observeFlightHook = observeFlight; holdWorkHook = holdWork; stageMarkHook = stageMark; keepReadersHook = keepReaders; attachHook = attach; + holdStagingHook = holdStaging; } /** External-source bridge (wired by enableExternalSource(); undefined while * no config is active — including after _resetExternalSourceConfig()). */ @@ -520,7 +527,15 @@ function blockedBy(nodes: Signal[], owner: Transaction, own = false): boole // The frame's own pending observer (off screen: this one holds nothing; // the next listed node may). if ((n as any)._type === EFFECT_RENDER) { - if (onScreen(n, judge ?? owner)) return true; + // #4544 (F-6): a frame born into the hold that is pending on what a + // settled verdict reader's display-ahead value asked for (`read`, + // CONFIG_AHEAD) does not hold it; the reader re-derives at the + // landing, and the frame with it. A lane's reveal still waits (S-29). + if ( + !(observeFlightHook && n._config & CONFIG_AHEAD && !owner._lane) && + onScreen(n, judge ?? owner) + ) + return true; continue; } for (let s = n._subs; s !== null; s = s._nextSub) { @@ -537,7 +552,9 @@ function blockedBy(nodes: Signal[], owner: Transaction, own = false): boole // shows nothing — a mount the frame has not revealed — and holds // nothing. if ( - (((r as any)._type === EFFECT_RENDER && r._statusFlags & STATUS_PENDING) || + (((r as any)._type === EFFECT_RENDER && + r._statusFlags & STATUS_PENDING && + !(observeFlightHook && r._config & CONFIG_AHEAD && !owner._lane)) || r._flags & REACTIVE_FRAME_READ || (r._config & CONFIG_REDERIVE && r._statusFlags === STATUS_PENDING)) && (s._gen === r._depGen || r._x?._error != null) && diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 3c15060f04..cb18ab50e2 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -112,6 +112,10 @@ export interface NodeExtension { /** Provenance (A18, #3331): the question this node's guess or latest * flight answers (`scheduler.question`); 0 when unstamped. */ _q: number; + /** #4544: the hold (or, before one, the flush clock) under which a verdict + * window read this node's staging (verdict.ts `holdStaging`). Stale once + * that hold lands or dissolves: nothing clears it. Unset until then. */ + _askedIn?: Transaction | number; /** Lanes (plan sec. 28) — the lane's value for this node while CONFIG_OVERRIDE is * set: a written guess, or a lane pass's derivation. `NOT_PENDING` when * none. The screen shows it once the lane has revealed (`_shown`); a diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 611d3b2e49..ac78ca8d45 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -13,6 +13,8 @@ import { CONFIG_CHILDREN_FORBIDDEN, CONFIG_GUESS, CONFIG_ASYNC, + CONFIG_OWN_PROBE, + CONFIG_FRAME, CONFIG_HELD, CONFIG_HELD_FRAME, CONFIG_IN_SNAPSHOT_SCOPE, @@ -47,6 +49,7 @@ import { context, creatorPass, creatorReads, + ext, joinPass, markLateLinker, prepareComputed, @@ -69,6 +72,7 @@ import { deleteFromHeap, enqueueSub } from "./heap.js"; import { blocked, dirtyQueue, + clock, flushTransaction, globalQueue, GlobalQueue, @@ -455,6 +459,9 @@ function verdictValue(el: Signal | Computed, c: Computed | null): prepareComputed(owner, false); if (c && tracking) { link(el, c); + // #4544: asked of this staging, under its hold or this flush. + if (el._pendingValue !== NOT_PENDING) + ext(el)._askedIn = el._config & CONFIG_HELD ? txOf(el) : flushTransaction || clock; if ((el as any)._fn) pullComputed(owner, c); } setVerdict(verdictValue); @@ -797,7 +804,37 @@ function attachWaits(node: Computed): boolean { return true; } -installVerdictHooks(observeFlight, holdWork, stageMark, keepReaders, attachWaits); +/** #4544: a verdict reader whose posture is only inherited (it read a + * verdict memo, and neither this pass nor its last opened a window, + * CONFIG_OWN_PROBE; no unanswered probe) is the hold's work where it reads + * the hold's own sync write: not an async memo's answer (a flight's, A10), + * not a lane's, and not a staging a window asked of under the hold that + * holds it, or in this flush (`_askedIn`: `[latest(x), x()]` through a memo + * stays atomic, D-26 — a mark that expires with that hold). It is served + * that staging — held (`frameRead`), or this flush's before it parks + * (`stagedScreen`), leaving the verdict lane an earlier read of the verdict + * memo seated it in (D-25: either read order). A frame (a render effect, a + * boundary's structure) reads the screen as before. */ +function holdStaging(c: Computed, el: Signal | Computed): unknown { + const a = el._x?._askedIn; + return ( + !( + c._config & (CONFIG_OWN_PROBE | CONFIG_FRAME) || + c._flags & (REACTIVE_WINDOW | REACTIVE_PROBE_UNANSWERED) + ) && + el._pendingValue !== NOT_PENDING && + !(el._config & (CONFIG_ASYNC | CONFIG_OVERRIDE)) && + a !== clock && + !( + typeof a == "object" && + resolveTx(a) === + resolveTx(el._config & CONFIG_HELD ? el._x!._transaction! : flushTransaction!) + ) && + (!passLane || (passLane._parent!._verdict === passLane && (setPassLane(null), true))) + ); +} + +installVerdictHooks(observeFlight, holdWork, stageMark, keepReaders, attachWaits, holdStaging); /** A store read inside an untracked `isPending` probe found a live mark on * a record with no node to carry it (store/affects.ts): not final. */ GlobalQueue._witnessMark = () => { diff --git a/packages/signals/tests/verdict-memo-held-branch-4544.test.ts b/packages/signals/tests/verdict-memo-held-branch-4544.test.ts new file mode 100644 index 0000000000..69133e0137 --- /dev/null +++ b/packages/signals/tests/verdict-memo-held-branch-4544.test.ts @@ -0,0 +1,257 @@ +/** + * #4544. A held update writes `session` and opens a branch that exists only + * for that write. Inside, `params` reads `session` and `chrome`; `chrome` + * reads an unrelated signal `plan`, written afterwards. + * + * `chrome` computes under the probe in its own body (A31 / D-25). `params` + * holds its own staging of the hold (`session`): it is that hold's work, and + * the verdict posture it inherits by reading `chrome` neither pulls it out + * nor serves it the hold's committed `session` (D-25, 2026-11-02). The + * branch reads the hold's session and the hold lands — whether the writes go + * through one `flush()` or several (F-10). + * + * `params` probing in its own body (inline `isPending(plan)`, or + * `isPending(session)` beside `session()`) is the hold's verdict reader that + * pass (F-6, D-25): it reads the screen — signed out — and the branch, born + * into the hold, asks for the signed-out fetch. That fetch derives from a + * display-ahead read, so it does not hold the hold (F-6): the hold lands, + * `params` re-derives at the landing (A15) and the branch shows rows. + */ +import { expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest, + untrack +} from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + flush(); +}; + +type Probe = "plain read" | "isPending" | "latest" | "inline isPending" | "same-node isPending"; +type Params = { plan: boolean; signedIn: boolean; busy?: boolean }; + +function page(probe: Probe) { + const [session, setSession] = createSignal(undefined); + const [plan, setPlan] = createSignal(false); + const [gate, setGate] = createSignal(0); + let release: () => void = () => {}; + let screen = ""; + const branchSaw: Params[] = []; + const chromeSaw: boolean[] = []; + let signedOutIssued = false; + const dispose = createRoot(dispose => { + const held = createMemo(() => { + const g = gate(); + return g === 0 ? g : new Promise(r => (release = () => r(g))); + }); + const out = createLoadingBoundary( + () => { + const chrome = createMemo(() => { + const v = + probe === "isPending" + ? (isPending(plan), plan()) + : probe === "latest" + ? latest(plan) + : plan(); + chromeSaw.push(v); + return v; + }); + const params = createMemo(() => + probe === "same-node isPending" + ? { plan: plan(), signedIn: session() !== undefined, busy: isPending(session) } + : { + plan: probe === "inline isPending" ? (isPending(plan), plan()) : chrome(), + signedIn: session() !== undefined + } + ); + const signedOutOnly = createMemo( + () => { + signedOutIssued = true; + return new Promise(() => {}); + }, + { lazy: true } + ); + const cond = createMemo(() => session() !== undefined); + const show = createMemo(() => { + if (!cond()) return "signed out"; + return untrack(() => { + let text = ""; + createRenderEffect( + () => { + const p = params(); + branchSaw.push(p); + return p.signedIn ? "rows" : signedOutOnly(); + }, + v => { + text = v as string; + } + ); + return () => text; + }); + }); + return createMemo(() => { + const h = held(); + let s: unknown = show(); + while (typeof s === "function") s = s(); + return `${h} ${s}`; + }); + }, + () => "root" + ); + createRenderEffect( + () => { + let v: unknown = out; + while (typeof v === "function") v = v(); + return v; + }, + v => { + screen = v as string; + } + ); + return dispose; + }); + return { + dispose, + screen: () => screen, + branchSaw, + chromeSaw, + setSession, + setPlan, + setGate, + release: () => release(), + signedOutIssued: () => signedOutIssued + }; +} + +type Shape = "split" | "one"; +/** `split`: the hold's writes in one `flush()`, `plan`'s in the next. `one`: + * all three in one `flush()`. */ +function write(p: ReturnType, shape: Shape) { + p.setGate(1); + p.setSession("acc"); + if (shape === "split") flush(); + p.setPlan(true); + flush(); +} + +for (const shape of ["split", "one"] as Shape[]) { + for (const probe of ["plain read", "isPending", "latest"] as Probe[]) + it(`${probe} upstream of a held branch (${shape} flush): the branch reads the hold's session and the hold lands`, async () => { + const p = page(probe); + try { + flush(); + expect(p.screen()).toBe("0 signed out"); + write(p, shape); + // `chrome`'s value follows its own probe; `params` reads the hold's + // `session` beside it. + expect(p.branchSaw.at(-1)).toMatchObject({ + plan: probe !== "isPending", + signedIn: true + }); + expect(p.screen()).toBe("0 signed out"); + p.release(); + await tick(); + await tick(); + expect(p.screen()).toBe("1 rows"); + } finally { + p.dispose(); + } + }); + + for (const probe of ["inline isPending", "same-node isPending"] as Probe[]) + it(`${probe} in params (${shape} flush): params reads the screen, and the hold lands (F-6)`, async () => { + const p = page(probe); + try { + flush(); + write(p, shape); + // A verdict reader this pass: the screen's session, signed out. + expect(p.branchSaw.at(-1)).toMatchObject({ signedIn: false }); + expect(p.screen()).toBe("0 signed out"); + p.release(); + for (let i = 0; i < 4; i++) await tick(); + // Its signed-out fetch held nothing: the hold landed, and `params` + // re-derived over the landed session. + expect(p.branchSaw.at(-1)).toEqual( + probe === "inline isPending" + ? { plan: true, signedIn: true } + : { plan: true, signedIn: true, busy: false } + ); + expect(p.screen()).toBe("1 rows"); + } finally { + p.dispose(); + } + }); +} + +// A window's mark is the hold's: a staging asked of under one hold is not +// asked of under the next. `x` was probed (`latest(x)`) while hold 1 held it; +// hold 1 lands and the probe goes away. Under hold 2, `params` — a verdict +// reader only by reading `chrome` — reads hold 2's `x` as it would had `x` +// never been probed. +for (const shape of ["split", "one"] as Shape[]) + for (const probed of [false, true]) + it(`a probe under an earlier hold leaves no mark on the next (${probed ? "probed" : "never probed"}, ${shape} flush)`, async () => { + const [x, setX] = createSignal(0); + const [plan, setPlan] = createSignal(false); + const [gate, setGate] = createSignal(0); + const [ask, setAsk] = createSignal(probed); + let release: () => void = () => {}; + const saw: number[] = []; + let screen = ""; + const dispose = createRoot(d => { + const held = createMemo(() => { + const g = gate(); + return g === 0 ? g : new Promise(r => (release = () => r(g))); + }); + const asker = createMemo(() => (ask() ? latest(x) : -1)); + const chrome = createMemo(() => (isPending(plan), plan())); + const params = createMemo(() => { + chrome(); + const v = x(); + saw.push(v); + return v; + }); + createRenderEffect( + () => `${held()} ${params()} ${asker()}`, + v => void (screen = v) + ); + return d; + }); + try { + flush(); + expect(screen).toBe(`0 0 ${probed ? 0 : -1}`); + // Hold 1: `x` = 1, asked of while held when `probed`. + setGate(1); + setX(1); + flush(); + release(); + await tick(); + await tick(); + expect(screen).toBe(`1 1 ${probed ? 1 : -1}`); + setAsk(false); + flush(); + expect(screen).toBe("1 1 -1"); + // Hold 2: `params` reads the hold's `x` either way. + setGate(2); + setX(2); + if (shape === "split") flush(); + setPlan(true); + flush(); + expect(saw.at(-1)).toBe(2); + expect(screen).toBe("1 1 -1"); + release(); + await tick(); + await tick(); + expect(screen).toBe("2 2 -1"); + } finally { + dispose(); + } + }); diff --git a/packages/web/test/verdict-memo-held-branch-4544.spec.tsx b/packages/web/test/verdict-memo-held-branch-4544.spec.tsx new file mode 100644 index 0000000000..567e451da7 --- /dev/null +++ b/packages/web/test/verdict-memo-held-branch-4544.spec.tsx @@ -0,0 +1,114 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// #4544 through `Show` + `Loading`. `chrome` owns the probe of an unrelated +// signal (A31 / D-25). `params`, holding the hold's staging of `session`, is +// that hold's work: the verdict posture it inherits by reading `chrome` does +// not serve it the committed `session`. The branch the hold opened reads the +// hold's session and the hold lands, in one `flush()` or several (F-10). +// A probe in `params`'s own body makes it the hold's verdict reader (F-6): +// it reads the screen, signed out; the signed-out fetch the branch then asks +// for holds nothing, the hold lands and `params` re-derives (A15). +import { expect, test } from "vitest"; +import { render } from "@solidjs/web"; +import { createMemo, createSignal, flush, isPending, latest, Loading, Show } from "solid-js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + flush(); +}; + +type Probe = "plain read" | "isPending" | "latest" | "inline isPending" | "same-node isPending"; +type Shape = "split" | "one"; +type Params = { plan: boolean; signedIn: boolean; busy?: boolean }; + +async function arrive(probe: Probe, shape: Shape) { + const [session, setSession] = createSignal(undefined); + const [plan, setPlan] = createSignal(false); + const [gate, setGate] = createSignal(0); + let release: () => void = () => {}; + const branchSaw: Params[] = []; + const el = document.createElement("div"); + const dispose = render(() => { + const held = createMemo(() => { + const g = gate(); + return g === 0 ? g : new Promise(r => (release = () => r(g))); + }); + const chrome = createMemo(() => + probe === "isPending" ? (isPending(plan), plan()) : probe === "latest" ? latest(plan) : plan() + ); + const params = createMemo(() => + probe === "same-node isPending" + ? { plan: plan(), signedIn: session() !== undefined, busy: isPending(session) } + : { + plan: probe === "inline isPending" ? (isPending(plan), plan()) : chrome(), + signedIn: session() !== undefined + } + ); + const signedOutOnly = createMemo(() => new Promise(() => {}), { lazy: true }); + return ( + root}> + {held()} + signed out}> + + {(() => { + const p = params(); + branchSaw.push(p); + return p.signedIn ? "rows" : signedOutOnly(); + })()} + + + + ); + }, el); + try { + flush(); + expect(el.textContent).toBe("0signed out"); + setGate(1); + setSession("acc"); + if (shape === "split") flush(); + setPlan(true); + flush(); + return { el, branchSaw, release: () => release(), dispose }; + } catch (e) { + dispose(); + throw e; + } +} + +for (const shape of ["split", "one"] as Shape[]) { + for (const probe of ["plain read", "isPending", "latest"] as Probe[]) + test(`${probe} (${shape} flush): the branch the hold opens reads the hold's session and the hold lands`, async () => { + const p = await arrive(probe, shape); + try { + expect(p.branchSaw.at(-1)).toMatchObject({ plan: probe !== "isPending", signedIn: true }); + expect(p.el.textContent).toBe("0signed out"); + p.release(); + await tick(); + await tick(); + expect(p.el.textContent).toBe("1rows"); + } finally { + p.dispose(); + } + }); + + for (const probe of ["inline isPending", "same-node isPending"] as Probe[]) + test(`${probe} in params (${shape} flush): params reads the screen, and the hold lands (F-6)`, async () => { + const p = await arrive(probe, shape); + try { + expect(p.branchSaw.at(-1)).toMatchObject({ signedIn: false }); + expect(p.el.textContent).toBe("0signed out"); + p.release(); + for (let i = 0; i < 4; i++) await tick(); + expect(p.branchSaw.at(-1)).toEqual( + probe === "inline isPending" + ? { plan: true, signedIn: true } + : { plan: true, signedIn: true, busy: false } + ); + expect(p.el.textContent).toBe("1rows"); + } finally { + p.dispose(); + } + }); +}