From a78d91a32dcb2cc2d53d780c61bf0ccd09151566 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:38:09 +0000 Subject: [PATCH 1/2] fix(signals): a reader mounted mid-flight reads a derived store like its memo (#4491) `createStore(d => { d.v = m1() })` over an async memo whose input reads `latest()` has a derive that is a verdict reader through what it reads (D-25 / F-6). At a re-ask it is served `m1`'s committed value and settles over it, so its leaves read as final: a reader that mounted mid-flight showed the old question's answer before the new one (in the mounting flush once #4479 skips the unread derive, at the landing before it), where a reader of `m1()` is held (F-2). D-30: a derived store reads like its memo. `pullFamily`: a tracked reader of a settled verdict-reader derive with no window of its own reads the flights among the derive's deps, as the memo's reader would, so it is held; the derive is that hold's work until it answers. `runProjectionComputed`: a derive whose pending is on its leaves' readers (`fam.woke`) reads plainly, as a pending node's pass does (#3884), so its landing is the answer the held reader shows. Store-only: no frozen floor moves. The #4479 review's probe forms (key read, Object.keys, JSON) are pinned against the memo form: mounted mid-flight with and without a plain write beside the flight, on screen at the re-ask, and mounted after the landing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/derived-store-midflight-mount.md | 5 + packages/signals/docs/RULES-DERIVED.md | 2 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/store/projection.ts | 9 + packages/signals/src/store/store.ts | 23 ++- ...unread-derive-midflight-mount-4491.test.ts | 168 ++++++++++++++++++ scripts/size/scenarios.js | 10 +- 7 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 .changeset/derived-store-midflight-mount.md create mode 100644 packages/signals/tests/unread-derive-midflight-mount-4491.test.ts diff --git a/.changeset/derived-store-midflight-mount.md b/.changeset/derived-store-midflight-mount.md new file mode 100644 index 0000000000..400d3850d3 --- /dev/null +++ b/.changeset/derived-store-midflight-mount.md @@ -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). diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 1c3bbe4137..60ed2905f1 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -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). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 5668d65508..0912c0359c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -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… | diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index da1b1492cc..3cb30bd496 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -19,6 +19,7 @@ import { attrHooks } from "../core/attribution-hooks.js"; import { CONFIG_AUTO_DISPOSE, + CONFIG_VERDICT, STATUS_ERROR, STATUS_PENDING, STATUS_UNINITIALIZED @@ -239,6 +240,14 @@ export function runProjectionComputed( const target: StoreTarget = (wrappedStore as any)[$TARGET]; const fam = target.fam!; const run = (fam.run = (fam.run || 0) + 1); + // The derive's pending is on its leaves' readers (`wakeFamily`) and no + // landing has settled them: it is pending, and its pass is the hold's work + // as a pending node's is (core `recompute`, #3884, D-25): it reads plainly, + // 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). A window in the pass + // re-stamps the posture (`markVerdictReader`). (The settle walk clears the + // derive's own pending before the landing re-runs it.) + if (fam.woke) owner._config &= ~CONFIG_VERDICT; let result: void | T | Promise | AsyncIterable; // Open loading window (seedLoadingValue): the observable store IS commit #0 // for the whole first flight — the derive works a detached shadow of the diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 001eca4c57..5b6d6e5a20 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -1869,7 +1869,28 @@ 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): its next pass reads plainly, and the + // landing it computes is the one the held reader shows, not the screen. + // A pass that reads the verdict memo again is a verdict reader again + // (core `recompute`); one that goes pending stays plain while its pending + // is on the leaves (`fam.woke`, projection.ts). (The window flag is + // REACTIVE_WINDOW, spelled from EFFECT_RENDER: an import of it keeps a + // dead copy in bundles without the store.) + if (fw._config & CONFIG_VERDICT && !(fw._flags & (EFFECT_RENDER << 21)) && tracking) + for (let l = fw._deps; l; l = l._nextDep) + if ((l._dep as Computed)._statusFlags & STATUS_PENDING) + ((fw._config &= ~CONFIG_VERDICT), readNode(l._dep)); + } } /** A projection's writes are its derive's: a staging made while the derive diff --git a/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts b/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts new file mode 100644 index 0000000000..b678a0b53c --- /dev/null +++ b/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts @@ -0,0 +1,168 @@ +/** + * #4491: a reader that mounts while an unread derived store's derive has a + * flight up reads the store like its memo (D-30, "in both forms"). + * + * `createStore(d => { d.v = m1() }, {})` over `m1 = async(m0)` with + * `m0 = memo(latest(s) + s)`: `m0` is a verdict reader, so `m1` and the + * derive are verdict readers through what they read (D-25 / F-6). Nothing + * reads the store when `m1` re-asks (s: 1 -> 2); a reader of the store then + * mounts mid-flight. + * + * - memo form (the reader reads `m1()`): the mount consumes unanswered async + * and is held until `m1` answers (F-2): ["r:off", "--mounted", "r:4"]. + * - store form, before: the derive was served `m1`'s committed value and + * settled over it (A15's stale reader), so its leaf read as final. The + * reader showed the old question's answer `r:2` before `r:4`: in the + * mounting flush itself when the unread derive skips the flight (#4479), + * at the landing otherwise (the derive's landing pass, a frame reader, + * read the screen). + * + * The forms the #4479 review probed (a key read, `Object.keys`, a JSON + * walk) must match the memo form, mounted mid-flight with and without a + * plain write beside the flight (with it, the store form showed `r:5`, an + * answer to a question between the two, before `r:6`). So must a reader + * already on screen at the re-ask, with the plain write (before the fix the + * whole-object readers showed `s:3` beside the old `v` ahead of `r:4`), and + * a reader mounted after the landing, which shows at once: the hold reaches + * neither. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + latest, + resetErrorHalt +} from "../src/index.js"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.useRealTimers(); + resetErrorHalt(); +}); +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +const tick = async (ms: number) => { + await vi.advanceTimersByTimeAsync(ms); + flush(); +}; + +type Form = "memo" | "storeKey" | "storeKeys" | "storeJSON"; +const storeForms = ["storeKey", "storeKeys", "storeJSON"] as const; + +/** The reader's display of `v`: every form shows the same text. */ +async function run(form: Form, at: "before" | "during" | "duringWrite" | "afterLanding") { + const log: string[] = []; + const [s, setS] = createSignal(1); + const [on, setOn] = createSignal(at === "before"); + createRoot(() => { + const m0 = createMemo(() => latest(s) + s()); + const m1 = createMemo(async () => { + const v = m0(); + await sleep(100); + return v; + }); + let read: () => unknown = () => m1(); + if (form !== "memo") { + const [st] = createStore<{ v?: number }>(d => { + d.v = m1(); + }, {}); + read = + form === "storeKey" + ? () => st.v + : form === "storeKeys" + ? () => (Object.keys(st).join() === "v" ? (st as any).v : "keys?") + : () => JSON.parse(JSON.stringify(st)).v; + } + if (at !== "during") + createRenderEffect( + () => `s:${s()}`, + v => void log.push(v) + ); + createRenderEffect( + () => (on() ? `r:${read()}` : "r:off"), + v => void log.push(v) + ); + }); + flush(); + await tick(150); + setS(2); // m1 re-asks + flush(); + log.push("--reask"); + await tick(20); + if (at === "during" || at === "duringWrite") { + setOn(true); // a reader mounts mid-flight; nothing read the store before + flush(); + log.push("--mounted"); + } + if (at !== "during") { + await tick(10); + setS(3); // a plain write beside the flight + flush(); + log.push("--plain write"); + } + await tick(300); + if (at === "afterLanding") { + setOn(true); + flush(); + log.push("--mounted"); + } + await tick(300); + return log; +} + +describe("a reader mounted mid-flight reads a derived store like its memo (#4491)", () => { + test("memo form: the mount is held until m1 answers (F-2)", async () => { + expect(await run("memo", "during")).toEqual(["r:off", "--reask", "--mounted", "r:4"]); + }); + test("memo form, a plain write beside the flight: held with it, re-asked once", async () => { + expect(await run("memo", "duringWrite")).toEqual([ + "s:1", + "r:off", + "s:2", + "--reask", + "--mounted", + "--plain write", + "r:6", + "s:3" + ]); + }); + for (const form of storeForms) + test(`${form}: held like the memo form, never the old question's r:2 (D-30)`, async () => { + expect(await run(form, "during")).toEqual(await run("memo", "during")); + // The write beside the flight shows no answer of a question between + // the two (`r:5`) either: the memo form's screen, frame for frame. + expect(await run(form, "duringWrite")).toEqual(await run("memo", "duringWrite")); + }); +}); + +describe("readers the hold does not reach (#4491)", () => { + test("memo form, reader on screen at the re-ask; reader mounted after the landing", async () => { + expect(await run("memo", "before")).toEqual([ + "s:1", + "r:2", + "--reask", + "--plain write", + "r:4", + "s:3", + "r:6" + ]); + expect(await run("memo", "afterLanding")).toEqual([ + "s:1", + "r:off", + "s:2", + "--reask", + "s:3", + "--plain write", + "r:6", + "--mounted" + ]); + }); + for (const form of storeForms) + test(`${form}: as the memo form`, async () => { + expect(await run(form, "before")).toEqual(await run("memo", "before")); + expect(await run(form, "afterLanding")).toEqual(await run("memo", "afterLanding")); + }); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 7bcbb47f12..8c0d89802b 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1119,8 +1119,9 @@ module.exports = [ // 0.01 KB; recorded minified never raised. // A tick whose writes net to the committed value pends no errored re-run (D-8, async-gen rerun on 5e68925c) (2026-10-30, over next @ 7135a77a): 14.89 -> 14.95 KB, recorded minified 43,875 -> 43,888 B (+13 B), measured locally (linux) at 14,932 B brotli. A signal read whose staging equals its committed value is not a read of the frame's future (`read`): +13 B minified, the rest brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record after the 2026-10-30 merges (#4454, #4457, #4461; over next @ 563b6b0): 14.95 KB -> 14.97 KB, recorded minified 43,888 -> 43,924 B (+36 B), measured locally (linux) at 14,951 B brotli. Growth is #4454's `stagedScreen` held-node check (+20 B minified in core.js) and #4457's same-error skip in `errorFamily`; together they put next over this cap. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "14.97 KB", - capMinified: 43924, + // A reader mounted mid-flight reads a derived store like its memo (#4491, D-30 / F-2; 2026-10-30, over next @ d0fc2851): 14.97 -> 15.01 KB, recorded minified 43,924 -> 44,038 B (+114 B; +108 B over next), measured locally (linux) at 14,997 B brotli. `pullFamily` reads the flight a verdict-reader derive was served the committed value of (its pending deps), so a reader that consumes unanswered async is held; a woke derive's pass reads plainly (projection.ts). Store-only: no frozen floor moves. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "15.01 KB", + capMinified: 44038, alias }, { @@ -4146,8 +4147,9 @@ module.exports = [ // 0.01 KB; recorded minified never raised. // A tick whose writes net to the committed value pends no errored re-run (D-8, async-gen rerun on 5e68925c) (2026-10-30, over next @ 7135a77a): 26.27 -> 26.31 KB, recorded minified 79,986 -> 79,999 B (+13 B), measured locally (linux) at 26,294 B brotli. A signal read whose staging equals its committed value is not a read of the frame's future (`read`): +13 B minified, the rest brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record after the 2026-10-30 merges (#4454, #4457, #4461; over next @ 563b6b0): 26.31 KB -> 26.33 KB, recorded minified 79,999 -> 80,035 B (+36 B), measured locally (linux) at 26,312 B brotli. Growth is #4454's `stagedScreen` held-node check (+20 B minified in core.js) and #4457's same-error skip in `errorFamily`; together they put next over this cap. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "26.33 KB", - capMinified: 80035, + // A reader mounted mid-flight reads a derived store like its memo (#4491, D-30 / F-2; 2026-10-30, over next @ d0fc2851): 26.33 -> 26.36 KB, recorded minified 80,035 -> 80,156 B (+121 B; +108 B over next), measured locally (linux) at 26,350 B brotli. `pullFamily` reads the flight a verdict-reader derive was served the committed value of, and a woke derive's pass reads plainly (store.ts, projection.ts). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "26.36 KB", + capMinified: 80156, alias }, { From 5df1f9029636422917c7a0dde4f9244944ef4e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:59:19 +0000 Subject: [PATCH 2/2] fix(signals): gate #4491 on the verdict engine; the derive's posture is never written outside its pass Review of a78d91a32: a verdict reader exists only with the verdict engine, so both halves of the fix now sit behind `observeFlightHook`. Store bundles without isPending/latest fold them away (createStore, compiled CSR and compiled hydrating at +0 B vs next), so the cap raises recorded for createStore and compiled CSR are dropped. The pull no longer clears the derive's CONFIG_VERDICT. That clear was sticky: when the flight's landing equalled the screen, the derive did not re-run and stayed a non-verdict reader into its next flight. The read now marks the family (`fam.w`), the derive's next pass consumes the mark and reads plainly, and a read that finds no flight up clears it. The posture stays the passes' own (core `recompute`). New tests: after the held mount, a later flight runs as for a store read from the start and as the memo form, after a changed landing and after an equal one. The equal-landing case fails on a78d91a32 (verdict=false after the landing). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/store/projection.ts | 28 +++++--- packages/signals/src/store/store.ts | 29 +++++--- packages/signals/src/store/target.ts | 4 ++ ...unread-derive-midflight-mount-4491.test.ts | 69 +++++++++++++++++++ scripts/size/scenarios.js | 10 ++- 5 files changed, 116 insertions(+), 24 deletions(-) diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index 3cb30bd496..6f8c99a4a7 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -35,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"; @@ -240,14 +246,18 @@ export function runProjectionComputed( const target: StoreTarget = (wrappedStore as any)[$TARGET]; const fam = target.fam!; const run = (fam.run = (fam.run || 0) + 1); - // The derive's pending is on its leaves' readers (`wakeFamily`) and no - // landing has settled them: it is pending, and its pass is the hold's work - // as a pending node's is (core `recompute`, #3884, D-25): it reads plainly, - // 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). A window in the pass - // re-stamps the posture (`markVerdictReader`). (The settle walk clears the - // derive's own pending before the landing re-runs it.) - if (fam.woke) owner._config &= ~CONFIG_VERDICT; + // 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 | AsyncIterable; // Open loading window (seedLoadingValue): the observable store IS commit #0 // for the whole first flight — the derive works a detached shadow of the diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 5b6d6e5a20..d1f50308dd 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -93,6 +93,7 @@ import { insertSubs, joinFuture, joinPassTx, + observeFlightHook, passLane, queuePendingNode, schedule, @@ -1879,17 +1880,27 @@ function pullFamily(target: StoreTarget): void { // 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): its next pass reads plainly, and the - // landing it computes is the one the held reader shows, not the screen. - // A pass that reads the verdict memo again is a verdict reader again - // (core `recompute`); one that goes pending stays plain while its pending - // is on the leaves (`fam.woke`, projection.ts). (The window flag is - // REACTIVE_WINDOW, spelled from EFFECT_RENDER: an import of it keeps a - // dead copy in bundles without the store.) - if (fw._config & CONFIG_VERDICT && !(fw._flags & (EFFECT_RENDER << 21)) && tracking) + // 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)._statusFlags & STATUS_PENDING) - ((fw._config &= ~CONFIG_VERDICT), readNode(l._dep)); + ((fam.w = 1), readNode(l._dep)); + } } } diff --git a/packages/signals/src/store/target.ts b/packages/signals/src/store/target.ts index 5d611e639a..9ccc5254bf 100644 --- a/packages/signals/src/store/target.ts +++ b/packages/signals/src/store/target.ts @@ -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 diff --git a/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts b/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts index b678a0b53c..e659101c4c 100644 --- a/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts +++ b/packages/signals/tests/unread-derive-midflight-mount-4491.test.ts @@ -37,6 +37,8 @@ import { latest, resetErrorHalt } from "../src/index.js"; +import { $REFRESH, CONFIG_VERDICT, STATUS_PENDING } from "../src/core/constants.js"; +import type { Computed } from "../src/core/types.js"; beforeEach(() => vi.useFakeTimers()); afterEach(() => { @@ -166,3 +168,70 @@ describe("readers the hold does not reach (#4491)", () => { expect(await run(form, "afterLanding")).toEqual(await run("memo", "afterLanding")); }); }); + +/** The derive's posture after the held mount. The read that held the reader + * writes nothing on the derive: it marks the derive's next pass (`fam.w`), + * a read that finds no flight up clears the mark, and the posture stays the + * pass's own reads (core `recompute`). A later flight of `m1`, the reader on + * screen, then runs as it does for a store read from the start, and as the + * memo form: the same screen, the same posture — whether the landing changed + * the leaf (the derive re-ran) or equalled it (it did not). + */ +describe("the derive's posture after the held mount (#4491)", () => { + async function later(form: "memo" | "storeKey", mountMidFlight: boolean, equal: boolean) { + const log: string[] = []; + const [s, setS] = createSignal(1); + const [on, setOn] = createSignal(!mountMidFlight); + let derive: Computed | null = null; + createRoot(() => { + const m0 = createMemo(() => latest(s) + s()); + const m1 = createMemo(async () => { + const v = m0(); + await sleep(100); + return equal ? 0 : v; + }); + let read: () => unknown = () => m1(); + if (form === "storeKey") { + const [st] = createStore<{ v?: number }>(d => { + d.v = m1(); + }, {}); + derive = (st as any)[$REFRESH]; + read = () => st.v; + } + createRenderEffect( + () => (on() ? `r:${read()}` : "r:off"), + v => void log.push(v) + ); + }); + const posture = (tag: string) => + log.push( + derive + ? `${tag} verdict=${!!(derive._config & CONFIG_VERDICT)} pending=${!!(derive._statusFlags & STATUS_PENDING)}` + : tag + ); + flush(); + await tick(150); + setS(2); // m1 re-asks + flush(); + await tick(20); + setOn(true); // mid-flight: held on the flight + flush(); + await tick(300); + posture("--landed"); + setS(3); // a later flight, the reader on screen + flush(); + posture("--reask"); + await tick(300); + // From the first landing on. + return log.slice(log.findIndex(x => x.startsWith("--landed")) - 1); + } + const screen = (log: string[]) => log.map(x => x.split(" ")[0]); + + for (const equal of [false, true]) + test(`a later flight after ${equal ? "an equal" : "a changed"} landing`, async () => { + const held = await later("storeKey", true, equal); + expect(held).toEqual(await later("storeKey", false, equal)); + expect(screen(held)).toEqual(screen(await later("memo", true, equal))); + expect(held).toContain("--landed verdict=true pending=false"); + }); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 8c0d89802b..7bcbb47f12 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1119,9 +1119,8 @@ module.exports = [ // 0.01 KB; recorded minified never raised. // A tick whose writes net to the committed value pends no errored re-run (D-8, async-gen rerun on 5e68925c) (2026-10-30, over next @ 7135a77a): 14.89 -> 14.95 KB, recorded minified 43,875 -> 43,888 B (+13 B), measured locally (linux) at 14,932 B brotli. A signal read whose staging equals its committed value is not a read of the frame's future (`read`): +13 B minified, the rest brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record after the 2026-10-30 merges (#4454, #4457, #4461; over next @ 563b6b0): 14.95 KB -> 14.97 KB, recorded minified 43,888 -> 43,924 B (+36 B), measured locally (linux) at 14,951 B brotli. Growth is #4454's `stagedScreen` held-node check (+20 B minified in core.js) and #4457's same-error skip in `errorFamily`; together they put next over this cap. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - // A reader mounted mid-flight reads a derived store like its memo (#4491, D-30 / F-2; 2026-10-30, over next @ d0fc2851): 14.97 -> 15.01 KB, recorded minified 43,924 -> 44,038 B (+114 B; +108 B over next), measured locally (linux) at 14,997 B brotli. `pullFamily` reads the flight a verdict-reader derive was served the committed value of (its pending deps), so a reader that consumes unanswered async is held; a woke derive's pass reads plainly (projection.ts). Store-only: no frozen floor moves. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "15.01 KB", - capMinified: 44038, + limit: "14.97 KB", + capMinified: 43924, alias }, { @@ -4147,9 +4146,8 @@ module.exports = [ // 0.01 KB; recorded minified never raised. // A tick whose writes net to the committed value pends no errored re-run (D-8, async-gen rerun on 5e68925c) (2026-10-30, over next @ 7135a77a): 26.27 -> 26.31 KB, recorded minified 79,986 -> 79,999 B (+13 B), measured locally (linux) at 26,294 B brotli. A signal read whose staging equals its committed value is not a read of the frame's future (`read`): +13 B minified, the rest brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record after the 2026-10-30 merges (#4454, #4457, #4461; over next @ 563b6b0): 26.31 KB -> 26.33 KB, recorded minified 79,999 -> 80,035 B (+36 B), measured locally (linux) at 26,312 B brotli. Growth is #4454's `stagedScreen` held-node check (+20 B minified in core.js) and #4457's same-error skip in `errorFamily`; together they put next over this cap. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - // A reader mounted mid-flight reads a derived store like its memo (#4491, D-30 / F-2; 2026-10-30, over next @ d0fc2851): 26.33 -> 26.36 KB, recorded minified 80,035 -> 80,156 B (+121 B; +108 B over next), measured locally (linux) at 26,350 B brotli. `pullFamily` reads the flight a verdict-reader derive was served the committed value of, and a woke derive's pass reads plainly (store.ts, projection.ts). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "26.36 KB", - capMinified: 80156, + limit: "26.33 KB", + capMinified: 80035, alias }, {