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
5 changes: 5 additions & 0 deletions .changeset/derived-store-midflight-mount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A reader of a derived store that mounts while the derive's input is in flight is held until it answers, as a reader of the async memo is, instead of showing the previous answer first: `createStore(d => { d.v = m1() })` over a memo that reads `latest()`, read mid-flight, no longer shows the old question's value before the new one (#4491).
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Effect-phase code that runs after the frame is decided reads the frame as it sta

**From:** F-9, F-1. **Scenarios:** S-36, S-37.

Pending is per node — per store key: a held write to a plain store pends exactly the touched keys; a derived store's leaves report the derive's new-question refetch like any async memo, in both forms, and a quiet re-ask (`refresh(store)`) is quiet on them too. A derived store's landing resumes the frame that asked it: when a consumer of its leaves goes async, the landed leaves are held with that frame and read stale and pending until it reveals (signal parity); an untouched sibling key is final. A derived store's seed is a draft for the derive, never an observable value: until the first resolution (first yield for a generator) every outside read — tracked, untracked, `in`, enumeration — throws `NotReadyError` (DEV in a component body: `PENDING_ASYNC_UNTRACKED_READ`); write-path reads see it. A seedless derived store (`createStore(fn)` with a derive that takes no draft) starts from an empty record, the same unobservable draft. `loadingValue` / `seedLoadingValue` promote a first value to commit #0: reads serve it, no boundary trips, no hold starts or extends, `isPending` is `false` on every axis until the first real landing — a hydration invariant (the server always answers `false`) — after which the node is an ordinary memo.
Pending is per node — per store key: a held write to a plain store pends exactly the touched keys; a derived store's leaves report the derive's new-question refetch like any async memo, in both forms, and a quiet re-ask (`refresh(store)`) is quiet on them too. So do the leaves of a derive that is a verdict reader only through what it reads (D-25) and was served a flight's committed value: a tracked reader of them reads the flight as the memo's reader would — one that mounts mid-flight is held (F-2) and shows the flight's answer, never the old question's — and the derive computes that landing plainly, as the hold's work (D-25) (#4491, `tests/unread-derive-midflight-mount-4491.test.ts`). A derived store's landing resumes the frame that asked it: when a consumer of its leaves goes async, the landed leaves are held with that frame and read stale and pending until it reveals (signal parity); an untouched sibling key is final. A derived store's seed is a draft for the derive, never an observable value: until the first resolution (first yield for a generator) every outside read — tracked, untracked, `in`, enumeration — throws `NotReadyError` (DEV in a component body: `PENDING_ASYNC_UNTRACKED_READ`); write-path reads see it. A seedless derived store (`createStore(fn)` with a derive that takes no draft) starts from an empty record, the same unobservable draft. `loadingValue` / `seedLoadingValue` promote a first value to commit #0: reads serve it, no boundary trips, no hold starts or extends, `isPending` is `false` on every axis until the first real landing — a hydration invariant (the server always answers `false`) — after which the node is an ordinary memo.

**Collapses:** [A9](./SPEC-ASYNC-SEMANTICS.md#a9-store-leaves-behind-a-firewall-report-the-firewalls-new-question-refetch), [A22](./SPEC-ASYNC-SEMANTICS.md#a22-pending-is-per-node-store-wide-only-for-the-firewalls-own-work) (amended 2026-10-03), [A25](./SPEC-ASYNC-SEMANTICS.md#a25-a-derived-stores-seed-is-a-draft-never-an-observable-value), [A27](./SPEC-ASYNC-SEMANTICS.md#a27-the-commit-0-loading-window-is-loading-class-and-verdict-quiet), the L2 stores ruling (statement 7), OS-R30–R36, PJ-R23/R30/R31. **Status:** standing; one adoption shape pinned `it.fails` (`adoption-unchanged-key-read-3706.test.ts`, a row first read after the adoption).

Expand Down
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| 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 lanes.ts×6 scheduler.ts×16 seat.ts×1 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 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×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 … |
| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×2 constants.ts×1 core.ts×4 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×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 unread-derive-midflight-mount-4491.test.ts×1 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 | 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… |
Expand Down
21 changes: 20 additions & 1 deletion packages/signals/src/store/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { attrHooks } from "../core/attribution-hooks.js";
import {
CONFIG_AUTO_DISPOSE,
CONFIG_VERDICT,
STATUS_ERROR,
STATUS_PENDING,
STATUS_UNINITIALIZED
Expand All @@ -34,7 +35,13 @@ import {
} from "../core/async.js";
import { enqueueSub } from "../core/heap.js";
import { getOwner, isDisposed } from "../core/owner.js";
import { insertSubs, question, schedule, setQuestion } from "../core/scheduler.js";
import {
insertSubs,
observeFlightHook,
question,
schedule,
setQuestion
} from "../core/scheduler.js";
import type { Computed, Signal } from "../core/types.js";
import type { Refreshable } from "../core/index.js";
import { optHooks } from "./hooks.js";
Expand Down Expand Up @@ -239,6 +246,18 @@ export function runProjectionComputed<T extends object>(
const target: StoreTarget = (wrappedStore as any)[$TARGET];
const fam = target.fam!;
const run = (fam.run = (fam.run || 0) + 1);
// A verdict reader's pass that is the hold's work reads plainly, as a
// pending node's is (core `recompute`, #3884, D-25), so the landing it
// computes is the one a reader held on it shows, not the screen a frame
// reader is served (#4491; D-30, F-2): its pending is on its leaves'
// readers (`wakeFamily`) with no landing to settle them yet — the settle
// walk clears the derive's own pending before the landing re-runs it — or
// a reader of the leaves reads a flight it was served the committed value
// of (`fam.w`, store.ts `pullFamily`; this pass consumes the mark). For
// this pass only: its reads set the posture again at its end, and a window
// in it re-stamps it (`markVerdictReader`). Only the verdict engine makes
// a verdict reader (`observeFlightHook`).
if (observeFlightHook && (fam.woke || fam.w)) ((fam.w = 0), (owner._config &= ~CONFIG_VERDICT));
let result: void | T | Promise<void | T> | AsyncIterable<void | T>;
// Open loading window (seedLoadingValue): the observable store IS commit #0
// for the whole first flight — the derive works a detached shadow of the
Expand Down
34 changes: 33 additions & 1 deletion packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import {
joinPassTx,
list,
laneStageHook,
observeFlightHook,
passLane,
queuePendingNode,
schedule,
Expand Down Expand Up @@ -1871,7 +1872,38 @@ function pullFamily(target: StoreTarget): void {
// (optimistic.ts `stands`, #4336).
if (verdict === null && fw._statusFlags & STATUS_PENDING && optHooks?.stands(target)) return;
readNode(fw);
} else untrack(() => readNode(fw));
} else {
untrack(() => readNode(fw));
// #4491: a derive that is a verdict reader only through what it reads (a
// verdict memo's value, D-25 / F-6; no window of its own) is served the
// committed value of a flight it reads (`observeFlight`, A15's stale
// reader) and settles over it: its leaves are the screen, not the
// flight's answer. A derived store reads like its memo (D-30): a tracked
// reader of the leaves reads that flight as the memo's reader would, so
// one that mounts mid-flight consumes unanswered async and is held (F-2)
// instead of shown the old question's answer. The derive is that hold's
// work until it answers (D-25): the read marks its next pass, which reads
// plainly (`fam.w`, projection.ts), so the landing it computes is the one
// the held reader shows. The mark is all it writes — the derive's posture
// stays its passes' own (core `recompute`) — and a read that finds no
// flight up clears it: a landing equal to the screen re-runs nothing, and
// the mark does not outlive the flight. Only the verdict engine makes a
// verdict reader (`observeFlightHook`: without it the branch folds away).
// (The window flag is REACTIVE_WINDOW, spelled from EFFECT_RENDER: an
// import of it keeps a dead copy in bundles without the store.)
if (
observeFlightHook &&
fw._config & CONFIG_VERDICT &&
!(fw._flags & (EFFECT_RENDER << 21)) &&
tracking
) {
const fam = target.fam!;
fam.w = 0;
for (let l = fw._deps; l; l = l._nextDep)
if ((l._dep as Computed<any>)._statusFlags & STATUS_PENDING)
((fam.w = 1), readNode(l._dep));
}
}
}

/** A projection's writes are its derive's: a staging made while the derive
Expand Down
4 changes: 4 additions & 0 deletions packages/signals/src/store/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export interface StoreFamily {
* landing has settled them yet: the next commit walks the family
* (`settleFamily`). A sync commit with no wake behind it walks nothing. */
woke?: boolean;
/** A tracked reader of the leaves reads a flight the derive was served
* the committed value of (store.ts `pullFamily`, #4491): the derive's next
* pass reads plainly (projection.ts). */
w?: number;
}

/** One setter's written keys; `u` is the batch's earlier setters' (the
Expand Down
Loading
Loading