Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/verdict-memo-flight-no-reask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question twice. Reading such a memo makes the async memo a verdict reader too (D-25). It was re-derived each time one of its verdict sources went pending, served a committed value it had already answered, and asked that question again; with two verdict sources it could ask a stale question after both had landed. Such a memo, one that calls neither `latest()` nor `isPending()` itself, now waits on a verdict source going pending like any derivation, and a re-run its hold makes reads plainly as the hold's work. It asks each question once. An async memo that calls `latest()` or `isPending()` itself is unchanged: it stays a verdict reader and shows the lane's answer ahead (F-6).

A reader that groups `isPending()` with the value over a held write, such as `[isPending(m1), m1()]`, now shows the lane's answer as a plain value reader does. A pass served a held memo's committed value no longer waits on that memo's flight, and a lane's reveal leaves the stale-reader mark the held transaction's landing needs to re-run it.
6 changes: 3 additions & 3 deletions packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,16 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | — | fuzz-findings-l2.test.ts×1 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` | 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` | core.ts×3 verdict.ts×2 | 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 verdict-contract.test.ts×2 | [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 verdict.ts×3 | 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 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×3 lanes.ts×4 scheduler.ts×15 verdict.ts×4 | 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 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 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×2 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 treeshake.test.ts×4 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 lanes.ts×5 scheduler.ts×15 verdict.ts×4 | 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 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 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×2 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 treeshake.test.ts×4 verdict-memo-flight-no-reask.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 | 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 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… |
| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | async.ts×1 constants.ts×1 core.ts×6 lanes.ts×1 scheduler.ts×2 types.ts×1 verdict.ts×5 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 latest-memo-inline-parity.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… |
| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | async.ts×1 constants.ts×1 core.ts×6 lanes.ts×1 scheduler.ts×2 types.ts×1 verdict.ts×5 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 latest-memo-inline-parity.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… |
| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:410` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… |
| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:417` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… |
| A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:231` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … |
Expand Down
22 changes: 19 additions & 3 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CONFIG_ASYNC,
CONFIG_AUTO_DISPOSE,
CONFIG_REDERIVE,
CONFIG_GUESS,
Expand All @@ -13,6 +14,7 @@ import {
EFFECT_USER,
NOT_PENDING,
REACTIVE_DIRTY,
REACTIVE_WINDOW,
REACTIVE_DISPOSED,
REACTIVE_RECOMPUTING_DEPS,
REACTIVE_REASK,
Expand Down Expand Up @@ -44,6 +46,7 @@ import {
schedule,
txOf,
freshHook,
holdWorkHook,
guessing,
laneStageHook
} from "./scheduler.js";
Expand All @@ -60,7 +63,7 @@ export function addPendingSource(el: Computed<any>, source: Computed<any>): bool
return true;
}

function removePendingSource(el: Computed<any>, source: Computed<any>): boolean {
export function removePendingSource(el: Computed<any>, source: Computed<any>): boolean {
const sources = el._x?._pendingSources;
if (!sources?.delete(source)) return false;
if (!sources.size) el._x!._pendingSources = undefined;
Expand Down Expand Up @@ -426,7 +429,10 @@ export function handleAsync<T>(
}

if (!thenable && !iterator) {
if (el._x !== null) el._x._inFlight = null;
if (el._x !== null) {
el._x._inFlight = null;
el._config &= ~CONFIG_ASYNC;
}
// A sync landing is the first real answer for a loadingValue node.
el._loading = false;
return result as T;
Expand Down Expand Up @@ -461,6 +467,9 @@ export function handleAsync<T>(
// fired _flightTeardown. A future non-recompute registration path must
// release it here before overwriting _inFlight.
ext(el)._inFlight = result as PromiseLike<T> | AsyncIterable<T>;
// D-25 (verdict.ts `holdWork`): an async memo whose answering pass opened
// no verdict window of its own (`markVerdictReader` clears the bit).
if (!(el._flags & REACTIVE_WINDOW)) el._config |= CONFIG_ASYNC;
// The question this flight answers (A18 provenance, lanes.ts): the action
// whose slice asked it; mainline is always the current question.
el._x!._q = question || MAINLINE_QUESTION;
Expand Down Expand Up @@ -988,8 +997,15 @@ export function propagateStatus(el: Computed<any>, status: number, error: any):
// Inheriting the error would make a probe's reader errored. A boundary's
// output (CONFIG_REDERIVE, boundaries.ts) the same: content or fallback
// is its pass's call.
// Not one D-25 makes the hold's work (verdict.ts `holdWork`): an async
// memo with no verdict window of its own reads a verdict memo's value,
// and that source going pending changes none of its reads — re-derived,
// it is served the committed value it already derived from (A10) and
// asks that question again. It waits on the source like any derivation
// (F-9), and asks its next question once, at the landing (#4397).
if (
sub._config & (CONFIG_REDERIVE | CONFIG_VERDICT) ||
sub._config & CONFIG_REDERIVE ||
(sub._config & CONFIG_VERDICT && !(pendingSource && holdWorkHook!(sub, el))) ||
(status === STATUS_PENDING && link._gen !== sub._depGen)
) {
enqueueSub(sub);
Expand Down
8 changes: 8 additions & 0 deletions packages/signals/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export const REACTIVE_SCREEN_READ = 1 << 18;
* later read of the same pass routed it into a verdict lane — that was not
* the probe's answer (lanes.ts). Per pass. */
export const REACTIVE_PROBE_UNANSWERED = 1 << 19;
/** The pass opened a verdict window of its own (`isPending`/`latest`) — not
* only read a verdict memo (F-6, core.ts `read`). An async memo whose pass
* did not is D-25's hold work while its flight is up (verdict.ts
* `holdWork`). Per pass (1 << 20 is lanes.ts's LEFT_SHOWN). */
export const REACTIVE_WINDOW = 1 << 21;

// Static configuration bits packed into Owner/Computed/Signal _config.
export const CONFIG_OWNED_WRITE = 1 << 0;
Expand Down Expand Up @@ -216,6 +221,9 @@ export const CONFIG_KEY_BAG = 1 << 28;
* joins nothing, so two mounts under one boundary made in separate flushes
* keep their holds apart (D-4, S-5 rows 1′–3′, #4283). */
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;

export const STATUS_NONE = 0;
export const STATUS_PENDING = 1 << 0;
Expand Down
Loading
Loading