From 3f6962ab0706e3a0b2900438b5d0db4821d3ad8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:24:32 +0000 Subject: [PATCH 01/14] fix(signals): a first answer landing in the tick a write re-asks it is answered, then re-asked (D-22, F-9) An async memo's first answer resolves, and in the same synchronous turn a plain write re-asks it; the promise reaction runs after the write, so the landing and the re-ask are processed in one flush. The runtime treated the answer as never produced: - `asyncWrite` dropped it outright when the write's notification had marked the node dirty (a stale-answer guard meant for re-asks). - Otherwise it landed staged, the re-ask left the node pending and uninitialized, and `isPending` gave it no verdict (`false` from the flush's probe, never re-run), while an identity memo or a derived store over it never derived from it (`latest` of either threw: [ROOT-L]). Now a first answer always lands; `isPending` of a node answered and re-asked in one flush is the re-ask's verdict (true); and a memo with no value of its own that reads it derives from the answer once, then re-runs on the missed-wake latch and waits on the re-ask holding it (a verdict engine hook, so the hello-world floor pays only the call). Amends D-22's 2026-10-28 clause per the 2026-10-30 ruling: a plain write re-asking the memo carries the answer like an action's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/same-tick-first-answer-reask.md | 5 + packages/signals/docs/RULES-DERIVED.md | 2 +- packages/signals/src/core/async.ts | 5 +- packages/signals/src/core/core.ts | 4 + packages/signals/src/core/scheduler.ts | 7 +- packages/signals/src/core/verdict.ts | 40 +++++- .../answered-then-reasked-same-tick.test.ts | 131 ++++++++++++++++++ .../same-tick-first-answer-reask.spec.ts | 40 ++++++ 8 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 .changeset/same-tick-first-answer-reask.md create mode 100644 packages/signals/tests/answered-then-reasked-same-tick.test.ts create mode 100644 packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts diff --git a/.changeset/same-tick-first-answer-reask.md b/.changeset/same-tick-first-answer-reask.md new file mode 100644 index 0000000000..5bc1ffb546 --- /dev/null +++ b/.changeset/same-tick-first-answer-reask.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An async memo's first answer that resolves in the same tick as a plain write re-asking it is answered, then re-asked (D-22, F-9): `isPending` of it is `true` while the new question flies, and an identity memo or derived store over it reads the answer through `latest` like the memo itself. diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index d1bce69775..352455f508 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -180,7 +180,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 **From:** F-6, F-10, F-9. **Scenarios:** S-25, S-45. -`latest(x)` answers the held write (the newest value a flush has processed, held or not), the optimistic value where one displays, the arrived truth under a superseded override, and the committed value of a node whose own flight is up (below the async nothing newer exists). A sync derivation of a held source is visible through it (the held recompute is a write path like any other). It throws `NotReadyError` only when no value has been produced — before a first landing, including an async memo with no resolution — in every scope. It returns `T`, never `undefined`. A lane held on async downstream of the read does not paint, and does not hide a value `latest` can already return. A memo behind a fallback, or in a branch not yet attached, has still produced its value, so `latest` returns it — a memo born held included: its plain read throws, and `latest` returns its pending value (F-9). Resolved async never pairs `[false, undefined]`. **A first landing a hold carries is a produced value (clarified 2026-10-28):** an async memo's first answer that lands in the same tick as an action writing its input is held with that action (D-4), and `latest` of it returns that answer while the plain read still throws (F-9); a `Loading` whose only read is `latest` of it shows the answer now (S-40). The frame is whole: the screen's committed input beside the answer to it. **Inside a flush, `latest()` of a staging not yet held is the frame's read like any other (clarified 2026-10-28):** it serves the staging, opens no hold, and leaves a plain read beside it in the same pass reading the staging too (F-10: one frame for every channel); if the flush parks, the pass re-derives once as a verdict reader (D-2, A10). A `latest()` read that manufactured a hold for the frame so its pass could run once made the plain reads after it see the screen in a flush that then committed (#4365). +`latest(x)` answers the held write (the newest value a flush has processed, held or not), the optimistic value where one displays, the arrived truth under a superseded override, and the committed value of a node whose own flight is up (below the async nothing newer exists). A sync derivation of a held source is visible through it (the held recompute is a write path like any other). It throws `NotReadyError` only when no value has been produced — before a first landing, including an async memo with no resolution — in every scope. It returns `T`, never `undefined`. A lane held on async downstream of the read does not paint, and does not hide a value `latest` can already return. A memo behind a fallback, or in a branch not yet attached, has still produced its value, so `latest` returns it — a memo born held included: its plain read throws, and `latest` returns its pending value (F-9). Resolved async never pairs `[false, undefined]`. **A first landing a hold carries is a produced value (clarified 2026-10-28, amended 2026-10-30):** an async memo's first answer that lands in the same tick as a write to its input that a hold carries — an action's, or a plain write re-asking it — is held with it (D-4), and `latest` of it returns that answer while the plain read still throws (F-9); a `Loading` whose only read is `latest` of it shows the answer now (S-40). The frame is whole: the screen's committed input beside the answer to it. **Inside a flush, `latest()` of a staging not yet held is the frame's read like any other (clarified 2026-10-28):** it serves the staging, opens no hold, and leaves a plain read beside it in the same pass reading the staging too (F-10: one frame for every channel); if the flush parks, the pass re-derives once as a verdict reader (D-2, A10). A `latest()` read that manufactured a hold for the frame so its pass could run once made the plain reads after it see the screen in a flush that then committed (#4365). **Collapses:** [A28](./SPEC-ASYNC-SEMANTICS.md#a28-a-write-becomes-visible-at-flush--to-every-channel) (`latest` reads the flushed staged world), [A11](./SPEC-ASYNC-SEMANTICS.md#a11-sync-derivations-of-held-sources-are-visible-through-latestispending), [A7](./SPEC-ASYNC-SEMANTICS.md#a7-resolved-async-never-reads-false-undefined), [A17](./SPEC-ASYNC-SEMANTICS.md#a17-an-active-override-is-the-displayed-value-until-its-transaction-commits-and-the-graphs-value-until-its-own-source-answers) (`latest` returns the arrived value). **Status:** standing. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 415dc4fc72..5a40894c0b 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -550,7 +550,10 @@ export function handleAsync( // If the node was dirtied by a newer write (optimistic override or regular), // skip this stale async result — the upcoming flush will recompute the node // with the new value, creating a fresh Promise that supersedes this one. - if (el._flags & REACTIVE_DIRTY) return; + // Not a first answer (D-22, F-9): it resolved before the write — only its + // reaction ran after — so it is a produced value, and the flush that + // re-asks lands it first (the node is answered, then re-asked). + if (el._flags & REACTIVE_DIRTY && !(el._statusFlags & STATUS_UNINITIALIZED)) return; const wasUninitialized = el._statusFlags & STATUS_UNINITIALIZED; landStatus(el); // Attribution hook: lets the engine snapshot state before the landing diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 9fadd8c078..d5e570bdd1 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -139,6 +139,7 @@ import { laneSeamsHook, laneServedHook, holdWorkHook, + answeredHook, laneWriteHook, observeFlightHook, wireExternalSourceHook @@ -2101,6 +2102,9 @@ export function read(el: Signal | Computed): T { ); } if (!(c._config & CONFIG_CHILDREN_FORBIDDEN) || owner._statusFlags & STATUS_UNINITIALIZED) { + // D-22, F-9: a node answered and re-asked in this flush (verdict.ts + // `answered`). + if (answeredHook?.(c as Computed, owner)) return el._pendingValue as T; // An untracked read of a pending node still re-runs its reader when // the node settles — unless nobody is reading (`spectating`, #3528) // or the reader is the node itself. diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 55bb7e5188..8f69c02b26 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -183,12 +183,17 @@ export let observeFlightHook: ((c: Computed, el: Computed) => boolean) /** D-25's hold work (verdict.ts `holdWork`): on `source` going pending, * whether verdict reader `n` waits on it instead of re-deriving. */ export let holdWorkHook: ((n: Computed, source: Computed) => unknown) | null = null; +/** D-22, F-9 (verdict.ts `answered`): whether reader `c` derives from + * pending `el`'s first value staged this flush. */ +export let answeredHook: ((c: Computed, el: Computed) => unknown) | null = null; export function installVerdictHooks( observeFlight: NonNullable, - holdWork: NonNullable + holdWork: NonNullable, + answered: NonNullable ): void { observeFlightHook = observeFlight; holdWorkHook = holdWork; + answeredHook = answered; } /** External-source bridge (wired by enableExternalSource(); undefined while * no config is active — including after _resetExternalSourceConfig()). */ diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 7c05b7d032..bdabaf0a74 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -24,6 +24,7 @@ import { REACTIVE_FRAME_READ, REACTIVE_JOINED, REACTIVE_LANE_READ, + REACTIVE_MISSED_WAKE, REACTIVE_PROBED, REACTIVE_REASK, REACTIVE_PROBE_UNANSWERED, @@ -228,7 +229,6 @@ function heldNotFinal(owner: Computed): boolean { /** `isPending`'s answer for an unheld node whose flight is up (cause ii). */ function pendingVerdict(c: Computed | null, el: Computed): void { - if (el._statusFlags & STATUS_UNINITIALIZED) return; if (probing && !quietPending(el)) probeFound = true; if (c && tracking) { if (el._config & CONFIG_HELD) verdictRead(c, txOf(el)); @@ -501,7 +501,14 @@ function verdictValue(el: Signal | Computed, c: Computed | null): return staged(el); } } else if (el._pendingValue !== NOT_PENDING) { - if (owner._statusFlags & STATUS_PENDING && !uninitialized) + // A first value staged this flush and re-asked in it (an async first + // answer landing in the tick a write re-asks it) is answered: the + // re-ask is a new question (F-9, D-22). An error is no value (D-41). + // `latest` serves the staging below. + if ( + owner._statusFlags & STATUS_PENDING && + (!uninitialized || (probing && !(el._config & CONFIG_STAGED_ERROR))) + ) pendingVerdict(tracked ? c : null, owner); else if (tracked && globalQueue._running) provisionalVerdict(c!, !latestActive && !uninitialized); @@ -571,10 +578,37 @@ function holdWork(n: Computed, source: Computed): unknown { ); } +/** D-22, F-9: `el`'s first value was staged this flush and `el` went + * pending on a re-ask in it — an async first answer landing in the tick a + * write re-asks it: answered, then re-asked; only the seam has not run. A + * memo `c` with no value of its own derives from that value, as a flush of + * the answer alone would have let it, then runs again (the missed-wake + * latch) and waits on the re-ask holding it: `latest`/`isPending` of it read + * as of `el` (E2). One pass: every read of it is served (the latch marks + * it); its wait on `el` is dropped, and the re-run — staged, pending on its + * own flight or on another source — throws and waits on `el` afresh. Not a + * render effect (the frame: its plain read throws), lane work, or a held + * node (the staging is the hold's). The verdict engine's: a plain read of + * a derivation holding the answer throws as one of a derivation with none, + * only `latest`/`isPending` tell them apart. */ +function answered(c: Computed, el: Computed): unknown { + return ( + c._statusFlags & el._statusFlags & STATUS_UNINITIALIZED && + el._pendingValue !== NOT_PENDING && + !(el._config & (CONFIG_HELD | CONFIG_STAGED_ERROR)) && + !(c as any)._type && + !passLane && + (c._flags & REACTIVE_MISSED_WAKE || + (c._pendingValue === NOT_PENDING && + (!(c._statusFlags & STATUS_PENDING) || c._x?._pendingSources?.delete(el)))) && + (c._flags |= REACTIVE_MISSED_WAKE) + ); +} + // Installed at module evaluation — present exactly when something imports // `latest` or `isPending`. (`read` dispatches to `verdictValue` through // `verdict`, set while a window is open.) -installVerdictHooks(observeFlight, holdWork); +installVerdictHooks(observeFlight, holdWork, answered); /** 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/answered-then-reasked-same-tick.test.ts b/packages/signals/tests/answered-then-reasked-same-tick.test.ts new file mode 100644 index 0000000000..3365079f46 --- /dev/null +++ b/packages/signals/tests/answered-then-reasked-same-tick.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest +} from "../src/index.js"; + +// D-22 (amended 2026-10-30), F-9: an async memo's first answer resolves, and +// in the same synchronous turn a plain write re-asks it; the promise reaction +// runs after the write, so the landing and the re-ask are processed in one +// flush. The answer exists from when it resolved: the memo is answered, then +// re-asked — `latest` shows the answer, the re-ask is a new question +// (`isPending` true), and a derivation of the memo reads the same (async +// generator E2/E3, `t S5 s0=2 t` and `t S6 s0=0 t`). +const task = () => new Promise(r => setTimeout(r, 0)); + +async function sameTick(view: "memo" | "identity" | "store", probe: "pending" | "latest") { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const m = createMemo(() => { + const v = 1 + s(); + return new Promise(resolve => asks.push({ v, resolve })); + }); + let x: () => number = m; + if (view === "identity") x = createMemo(() => m()); + else if (view === "store") { + const [st] = createStore<{ v?: number }>(d => { + d.v = m(); + }, {}); + x = () => st.v!; + } + let shown = ""; + createRoot(() => { + const b = createLoadingBoundary( + () => { + createRenderEffect( + () => (probe === "pending" ? `p:${isPending(x)}` : `l:${latest(x)}`), + v => { + shown = v; + } + ); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + if (v === "fallback") shown = "fallback"; + }); + }); + flush(); + await task(); + flush(); + expect(shown).toBe("fallback"); + // `m:2` resolves; before its reaction runs, `s = 2` asks `m:3`. + const first = asks.shift()!; + first.resolve(first.v); + setS(2); + await task(); + flush(); + expect(asks.map(a => a.v)).toEqual([3]); + return shown; +} + +describe("a first answer landing in the tick a plain write re-asks it", () => { + for (const view of ["memo", "identity", "store"] as const) { + it(`${view}: isPending is true — the re-ask is a new question (F-9)`, async () => { + expect(await sameTick(view, "pending")).toBe("p:true"); + }); + it(`${view}: latest shows the answer (D-22)`, async () => { + expect(await sameTick(view, "latest")).toBe("l:2"); + }); + } +}); + +// The derivation's pass that reads the answer is served it once: a second read +// in the same pass is served too, and its re-run waits on the re-ask — also +// when the pass first stopped on another source still loading. +it("a derivation reading the answer twice, or beside a source still loading, settles", async () => { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const m = createMemo(() => { + const v = 1 + s(); + return new Promise(resolve => asks.push({ v, resolve })); + }); + let resolveOther!: (v: number) => void; + const other = createMemo(() => new Promise(r => (resolveOther = r))); + const twice = createMemo(() => m() + m()); + const beside = createMemo(() => m() + other()); + const seen: string[] = []; + const shown = (probe: () => string, name: string) => { + const b = createLoadingBoundary( + () => { + createRenderEffect(probe, v => void seen.push(`${name} ${v}`)); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, () => {}); + }; + createRoot(() => { + shown(() => `${latest(twice)} ${isPending(twice)}`, "twice"); + shown(() => `${latest(beside)} ${isPending(beside)}`, "beside"); + }); + flush(); + await task(); + flush(); + const first = asks.shift()!; + first.resolve(first.v); + setS(2); + await task(); + flush(); + expect(asks.map(a => a.v)).toEqual([3]); + expect(seen).toEqual(["twice 4 true"]); + resolveOther(10); + await task(); + flush(); + // `beside` never derived a value: its pass over the answer stopped on + // `other`, and its re-run waits on the re-ask. + expect(seen).toEqual(["twice 4 true"]); + const next = asks.shift()!; + next.resolve(next.v); + await task(); + flush(); + expect(seen).toEqual(["twice 4 true", "twice 6 false", "beside 13 false"]); +}); diff --git a/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts b/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts new file mode 100644 index 0000000000..1171852731 --- /dev/null +++ b/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + */ +// async-gen rerun 2026-10-30, seed 3289 (E2, E3; D-22 amended 2026-10-30, +// F-9): an async memo's first answer resolves, and in the same synchronous +// turn a plain write re-asks it. The promise reaction runs after the write, +// so the landing and the re-ask are processed in one flush. The answer +// exists from when it resolved: `m0` is answered, then re-asked. `latest` +// shows the answer, the re-ask is a new question (`isPending` true), and an +// identity memo or a derived store over `m0` reads the same. The plain form +// showed `pm0:false`; the identity and store forms stayed on `[ROOT-L]`. +import { expect, test } from "vitest"; +import { runScenario } from "./run.js"; +import type { Scenario } from "./scenario.js"; + +const scenario = (s0: number, how: "pending" | "latest", pick: number, write: number) => + ({ + signals: [s0], + memos: [{ async: true, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: null }], + tree: { + t: "leaf", + text: [], + attrs: [{ how, src: { k: "m", i: 0 } }], + grouped: how === "pending" + }, + steps: [{ t: "task" }, { t: "settle", pick }, { t: "write", s: 0, v: write }, { t: "task" }] + }) as Scenario; + +for (const [name, sc, final] of [ + // [s0=1 m0=async(s0)] :: t S5 s0=2 t + ["isPending", scenario(1, "pending", 5, 2), ""], + // [s0=2 m0=async(s0)] :: t S6 s0=0 t + ["latest", scenario(2, "latest", 6, 0), ""] +] as const) + test(`${name}: a first answer landing in the tick a write re-asks it is answered`, async () => { + for (const variant of [{}, { identity: true }, { store: true }]) { + const r = await runScenario(sc, variant); + expect(r.screens.at(-1), JSON.stringify(variant)).toBe(final); + } + }); From ffbccfc978d1900de73b6ab3d29e547f285f259d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:24:32 +0000 Subject: [PATCH 02/14] fix(signals): a first answer landing in the tick a write re-asks it is answered, then re-asked (D-22, F-9) An async memo's first answer resolves, and in the same synchronous turn a plain write re-asks it; the promise reaction runs after the write, so the landing and the re-ask are processed in one flush. The runtime treated the answer as never produced: - `asyncWrite` dropped it outright when the write's notification had marked the node dirty (a stale-answer guard meant for re-asks). - Otherwise it landed staged, the re-ask left the node pending and uninitialized, and `isPending` gave it no verdict (`false` from the flush's probe, never re-run), while an identity memo or a derived store over it never derived from it (`latest` of either threw: [ROOT-L]). Now a first answer always lands; `isPending` of a node answered and re-asked in one flush is the re-ask's verdict (true); and a memo with no value of its own that reads it derives from the answer once, then re-runs on the missed-wake latch and waits on the re-ask holding it (in `read`'s pending path, behind the verdict engine's hook so the floor folds it). Amends D-22's 2026-10-28 clause per the 2026-10-30 ruling: a plain write re-asking the memo carries the answer like an action's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/same-tick-first-answer-reask.md | 5 + packages/signals/docs/RULES-DERIVED.md | 2 +- packages/signals/src/core/async.ts | 5 +- packages/signals/src/core/core.ts | 26 ++++ packages/signals/src/core/verdict.ts | 10 +- .../answered-then-reasked-same-tick.test.ts | 133 ++++++++++++++++++ .../same-tick-first-answer-reask.spec.ts | 40 ++++++ 7 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 .changeset/same-tick-first-answer-reask.md create mode 100644 packages/signals/tests/answered-then-reasked-same-tick.test.ts create mode 100644 packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts diff --git a/.changeset/same-tick-first-answer-reask.md b/.changeset/same-tick-first-answer-reask.md new file mode 100644 index 0000000000..5bc1ffb546 --- /dev/null +++ b/.changeset/same-tick-first-answer-reask.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An async memo's first answer that resolves in the same tick as a plain write re-asking it is answered, then re-asked (D-22, F-9): `isPending` of it is `true` while the new question flies, and an identity memo or derived store over it reads the answer through `latest` like the memo itself. diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index d1bce69775..352455f508 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -180,7 +180,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 **From:** F-6, F-10, F-9. **Scenarios:** S-25, S-45. -`latest(x)` answers the held write (the newest value a flush has processed, held or not), the optimistic value where one displays, the arrived truth under a superseded override, and the committed value of a node whose own flight is up (below the async nothing newer exists). A sync derivation of a held source is visible through it (the held recompute is a write path like any other). It throws `NotReadyError` only when no value has been produced — before a first landing, including an async memo with no resolution — in every scope. It returns `T`, never `undefined`. A lane held on async downstream of the read does not paint, and does not hide a value `latest` can already return. A memo behind a fallback, or in a branch not yet attached, has still produced its value, so `latest` returns it — a memo born held included: its plain read throws, and `latest` returns its pending value (F-9). Resolved async never pairs `[false, undefined]`. **A first landing a hold carries is a produced value (clarified 2026-10-28):** an async memo's first answer that lands in the same tick as an action writing its input is held with that action (D-4), and `latest` of it returns that answer while the plain read still throws (F-9); a `Loading` whose only read is `latest` of it shows the answer now (S-40). The frame is whole: the screen's committed input beside the answer to it. **Inside a flush, `latest()` of a staging not yet held is the frame's read like any other (clarified 2026-10-28):** it serves the staging, opens no hold, and leaves a plain read beside it in the same pass reading the staging too (F-10: one frame for every channel); if the flush parks, the pass re-derives once as a verdict reader (D-2, A10). A `latest()` read that manufactured a hold for the frame so its pass could run once made the plain reads after it see the screen in a flush that then committed (#4365). +`latest(x)` answers the held write (the newest value a flush has processed, held or not), the optimistic value where one displays, the arrived truth under a superseded override, and the committed value of a node whose own flight is up (below the async nothing newer exists). A sync derivation of a held source is visible through it (the held recompute is a write path like any other). It throws `NotReadyError` only when no value has been produced — before a first landing, including an async memo with no resolution — in every scope. It returns `T`, never `undefined`. A lane held on async downstream of the read does not paint, and does not hide a value `latest` can already return. A memo behind a fallback, or in a branch not yet attached, has still produced its value, so `latest` returns it — a memo born held included: its plain read throws, and `latest` returns its pending value (F-9). Resolved async never pairs `[false, undefined]`. **A first landing a hold carries is a produced value (clarified 2026-10-28, amended 2026-10-30):** an async memo's first answer that lands in the same tick as a write to its input that a hold carries — an action's, or a plain write re-asking it — is held with it (D-4), and `latest` of it returns that answer while the plain read still throws (F-9); a `Loading` whose only read is `latest` of it shows the answer now (S-40). The frame is whole: the screen's committed input beside the answer to it. **Inside a flush, `latest()` of a staging not yet held is the frame's read like any other (clarified 2026-10-28):** it serves the staging, opens no hold, and leaves a plain read beside it in the same pass reading the staging too (F-10: one frame for every channel); if the flush parks, the pass re-derives once as a verdict reader (D-2, A10). A `latest()` read that manufactured a hold for the frame so its pass could run once made the plain reads after it see the screen in a flush that then committed (#4365). **Collapses:** [A28](./SPEC-ASYNC-SEMANTICS.md#a28-a-write-becomes-visible-at-flush--to-every-channel) (`latest` reads the flushed staged world), [A11](./SPEC-ASYNC-SEMANTICS.md#a11-sync-derivations-of-held-sources-are-visible-through-latestispending), [A7](./SPEC-ASYNC-SEMANTICS.md#a7-resolved-async-never-reads-false-undefined), [A17](./SPEC-ASYNC-SEMANTICS.md#a17-an-active-override-is-the-displayed-value-until-its-transaction-commits-and-the-graphs-value-until-its-own-source-answers) (`latest` returns the arrived value). **Status:** standing. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 415dc4fc72..5a40894c0b 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -550,7 +550,10 @@ export function handleAsync( // If the node was dirtied by a newer write (optimistic override or regular), // skip this stale async result — the upcoming flush will recompute the node // with the new value, creating a fresh Promise that supersedes this one. - if (el._flags & REACTIVE_DIRTY) return; + // Not a first answer (D-22, F-9): it resolved before the write — only its + // reaction ran after — so it is a produced value, and the flush that + // re-asks lands it first (the node is answered, then re-asked). + if (el._flags & REACTIVE_DIRTY && !(el._statusFlags & STATUS_UNINITIALIZED)) return; const wasUninitialized = el._statusFlags & STATUS_UNINITIALIZED; landStatus(el); // Attribution hook: lets the engine snapshot state before the landing diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 9fadd8c078..e3d7932e76 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2101,6 +2101,32 @@ export function read(el: Signal | Computed): T { ); } if (!(c._config & CONFIG_CHILDREN_FORBIDDEN) || owner._statusFlags & STATUS_UNINITIALIZED) { + // D-22 (amended 2026-10-30), F-9: `el`'s first value was staged + // this flush and `el` went pending on a re-ask in it — an async + // first answer landing in the tick a write re-asks it: answered, + // then re-asked. A memo with no value, waiting on `el`, derives from + // that value, as a flush of the answer alone would have let it, + // then runs again (the missed-wake latch) and waits on the re-ask + // holding it: `latest`/`isPending` of it read as of `el`. One pass: + // every read of it is served (the latch marks it); its wait on `el` + // is dropped, so the re-run throws and waits on `el` afresh. Not a + // render effect (the frame: its plain read throws), lane work, or a + // held node (the staging is the hold's). A plain read of such a memo + // throws as one of a memo with no value; only `latest`/`isPending` + // tell them apart, so it is the verdict engine's. + if ( + observeFlightHook && + (c as Computed)._statusFlags & owner._statusFlags & STATUS_UNINITIALIZED && + el._pendingValue !== NOT_PENDING && + !(el._config & (CONFIG_HELD | CONFIG_STAGED_ERROR)) && + !(c as any)._type && + (c._flags & REACTIVE_MISSED_WAKE || + ((c as Computed)._pendingValue === NOT_PENDING && + (!((c as Computed)._statusFlags & STATUS_PENDING) || + (c as Computed)._x?._pendingSources?.delete(owner)) && + (c._flags |= REACTIVE_MISSED_WAKE))) + ) + return el._pendingValue as T; // An untracked read of a pending node still re-runs its reader when // the node settles — unless nobody is reading (`spectating`, #3528) // or the reader is the node itself. diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 7c05b7d032..5f2ba01c24 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -228,7 +228,6 @@ function heldNotFinal(owner: Computed): boolean { /** `isPending`'s answer for an unheld node whose flight is up (cause ii). */ function pendingVerdict(c: Computed | null, el: Computed): void { - if (el._statusFlags & STATUS_UNINITIALIZED) return; if (probing && !quietPending(el)) probeFound = true; if (c && tracking) { if (el._config & CONFIG_HELD) verdictRead(c, txOf(el)); @@ -501,7 +500,14 @@ function verdictValue(el: Signal | Computed, c: Computed | null): return staged(el); } } else if (el._pendingValue !== NOT_PENDING) { - if (owner._statusFlags & STATUS_PENDING && !uninitialized) + // A first value staged this flush and re-asked in it (an async first + // answer landing in the tick a write re-asks it) is answered: the + // re-ask is a new question (F-9, D-22). An error is no value (D-41). + // `latest` serves the staging below. + if ( + owner._statusFlags & STATUS_PENDING && + (!uninitialized || (probing && !(el._config & CONFIG_STAGED_ERROR))) + ) pendingVerdict(tracked ? c : null, owner); else if (tracked && globalQueue._running) provisionalVerdict(c!, !latestActive && !uninitialized); diff --git a/packages/signals/tests/answered-then-reasked-same-tick.test.ts b/packages/signals/tests/answered-then-reasked-same-tick.test.ts new file mode 100644 index 0000000000..9f3d2679ee --- /dev/null +++ b/packages/signals/tests/answered-then-reasked-same-tick.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest +} from "../src/index.js"; + +// D-22 (amended 2026-10-30), F-9: an async memo's first answer resolves, and +// in the same synchronous turn a plain write re-asks it; the promise reaction +// runs after the write, so the landing and the re-ask are processed in one +// flush. The answer exists from when it resolved: the memo is answered, then +// re-asked — `latest` shows the answer, the re-ask is a new question +// (`isPending` true), and a derivation of the memo reads the same (async +// generator E2/E3, `t S5 s0=2 t` and `t S6 s0=0 t`). +const task = () => new Promise(r => setTimeout(r, 0)); + +async function sameTick(view: "memo" | "identity" | "store", probe: "pending" | "latest") { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const m = createMemo(() => { + const v = 1 + s(); + return new Promise(resolve => asks.push({ v, resolve })); + }); + let x: () => number = m; + if (view === "identity") x = createMemo(() => m()); + else if (view === "store") { + const [st] = createStore<{ v?: number }>(d => { + d.v = m(); + }, {}); + x = () => st.v!; + } + let leaf = ""; + let output = ""; + createRoot(() => { + const b = createLoadingBoundary( + () => { + createRenderEffect( + () => (probe === "pending" ? `p:${isPending(x)}` : `l:${latest(x)}`), + v => { + leaf = v; + } + ); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + output = v; + }); + }); + const shown = () => (output === "content" ? leaf : output); + flush(); + await task(); + flush(); + expect(shown()).toBe("fallback"); + // `m:2` resolves; before its reaction runs, `s = 2` asks `m:3`. + const first = asks.shift()!; + first.resolve(first.v); + setS(2); + await task(); + flush(); + expect(asks.map(a => a.v)).toEqual([3]); + return shown(); +} + +describe("a first answer landing in the tick a plain write re-asks it", () => { + for (const view of ["memo", "identity", "store"] as const) { + it(`${view}: isPending is true — the re-ask is a new question (F-9)`, async () => { + expect(await sameTick(view, "pending")).toBe("p:true"); + }); + it(`${view}: latest shows the answer (D-22)`, async () => { + expect(await sameTick(view, "latest")).toBe("l:2"); + }); + } +}); + +// The derivation's pass that reads the answer is served it once: a second read +// in the same pass is served too, and its re-run waits on the re-ask — also +// when the pass first stopped on another source still loading. +it("a derivation reading the answer twice, or beside a source still loading, settles", async () => { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const m = createMemo(() => { + const v = 1 + s(); + return new Promise(resolve => asks.push({ v, resolve })); + }); + let resolveOther!: (v: number) => void; + const other = createMemo(() => new Promise(r => (resolveOther = r))); + const twice = createMemo(() => m() + m()); + const beside = createMemo(() => m() + other()); + const seen: string[] = []; + const shown = (probe: () => string, name: string) => { + const b = createLoadingBoundary( + () => { + createRenderEffect(probe, v => void seen.push(`${name} ${v}`)); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, () => {}); + }; + createRoot(() => { + shown(() => `${latest(twice)} ${isPending(twice)}`, "twice"); + shown(() => `${latest(beside)} ${isPending(beside)}`, "beside"); + }); + flush(); + await task(); + flush(); + const first = asks.shift()!; + first.resolve(first.v); + setS(2); + await task(); + flush(); + expect(asks.map(a => a.v)).toEqual([3]); + expect(seen).toEqual(["twice 4 true"]); + resolveOther(10); + await task(); + flush(); + // `beside` never derived a value: its pass over the answer stopped on + // `other`, and its re-run waits on the re-ask. + expect(seen).toEqual(["twice 4 true"]); + const next = asks.shift()!; + next.resolve(next.v); + await task(); + flush(); + expect(seen).toEqual(["twice 4 true", "twice 6 false", "beside 13 false"]); +}); diff --git a/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts b/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts new file mode 100644 index 0000000000..1171852731 --- /dev/null +++ b/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + */ +// async-gen rerun 2026-10-30, seed 3289 (E2, E3; D-22 amended 2026-10-30, +// F-9): an async memo's first answer resolves, and in the same synchronous +// turn a plain write re-asks it. The promise reaction runs after the write, +// so the landing and the re-ask are processed in one flush. The answer +// exists from when it resolved: `m0` is answered, then re-asked. `latest` +// shows the answer, the re-ask is a new question (`isPending` true), and an +// identity memo or a derived store over `m0` reads the same. The plain form +// showed `pm0:false`; the identity and store forms stayed on `[ROOT-L]`. +import { expect, test } from "vitest"; +import { runScenario } from "./run.js"; +import type { Scenario } from "./scenario.js"; + +const scenario = (s0: number, how: "pending" | "latest", pick: number, write: number) => + ({ + signals: [s0], + memos: [{ async: true, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: null }], + tree: { + t: "leaf", + text: [], + attrs: [{ how, src: { k: "m", i: 0 } }], + grouped: how === "pending" + }, + steps: [{ t: "task" }, { t: "settle", pick }, { t: "write", s: 0, v: write }, { t: "task" }] + }) as Scenario; + +for (const [name, sc, final] of [ + // [s0=1 m0=async(s0)] :: t S5 s0=2 t + ["isPending", scenario(1, "pending", 5, 2), ""], + // [s0=2 m0=async(s0)] :: t S6 s0=0 t + ["latest", scenario(2, "latest", 6, 0), ""] +] as const) + test(`${name}: a first answer landing in the tick a write re-asks it is answered`, async () => { + for (const variant of [{}, { identity: true }, { store: true }]) { + const r = await runScenario(sc, variant); + expect(r.screens.at(-1), JSON.stringify(variant)).toBe(final); + } + }); From a300836b7f16f421ec4bd2584162b30e55bdc81d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:16:51 +0000 Subject: [PATCH 03/14] test(signals): the seat shadow seats a first answer served in its re-ask's flush The reader-seat table (#4438) predicted NotReady (row D3) for a memo with no value reading a first answer staged in the flush that re-asks it; under D-22 (amended 2026-10-30) the memo derives from it once. Row D3' predicts the staging (P), and `read` records the slot it serves. __TEST__ only: dist is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/core.ts | 4 +++- packages/signals/src/core/seat.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 64e7915fb5..713b9f876c 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2197,8 +2197,10 @@ export function read(el: Signal | Computed): T { (!((c as Computed)._statusFlags & STATUS_PENDING) || (c as Computed)._x?._pendingSources?.delete(owner)) && (c._flags |= REACTIVE_MISSED_WAKE))) - ) + ) { + if (__TEST__) shadowActual(SLOT_P); return el._pendingValue as T; + } // An untracked read of a pending node still re-runs its reader when // the node settles — unless nobody is reading (`spectating`, #3528) // or the reader is the node itself. diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index 8b0100f45e..dc77dd3b07 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -59,6 +59,7 @@ import { EFFECT_RENDER, NOT_PENDING, REACTIVE_JOINED, + REACTIVE_MISSED_WAKE, REACTIVE_RECOMPUTING_DEPS, REACTIVE_STAGED_READ, STATUS_ERROR, @@ -68,6 +69,7 @@ import { import { clock, flushTransaction, + observeFlightHook, globalQueue, GlobalQueue, latestOpened, @@ -914,6 +916,20 @@ export function slot( if (running) return carry(pred("V4", screen, MARK_ST | MARK_AH, flushTransaction)); return carry(pred("V4", screen)); } + // D-22 (amended 2026-10-30), F-9: a first answer staged this flush and + // re-asked in it — answered, then re-asked. A pass with no value of its + // own derives from it, once (`read`). + if ( + observeFlightHook && + cUninit && + uninit && + staged && + !held && + !stagedError && + !isEffect && + (c!._flags & REACTIVE_MISSED_WAKE || c!._x?._pendingSources?.has(el as Computed)) + ) + return carry(pred(rowBase + "D3'", SLOT_P)); return carry(pred(leaf ? "L5" : rowBase + "D3", SLOT_NR)); // F-2 D-28 / F-2 D-9 } From 3af2ce56ae85795ea12582e435127bd409c15c47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:18:12 +0000 Subject: [PATCH 04/14] chore(size): raise caps for the same-tick first answer isPending/latest +37 B brotli (+151 B minified) and the compiled live page: the first-answer serve in `read`'s pending path and the `isPending` verdict for it. The observe-attribution and compiled scenarios carry the core half (+10 B minified on every floor; the rest is brotli layout). Caps at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/scenarios.js | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ff93f301a5..f01ceb8ce8 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1438,9 +1438,10 @@ module.exports = [ // (30,408 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. // A held memo's parked reader re-derives at the seam (#4439, 2026-10-30, over next @ b249ad96): 11.04 KB -> 11.07 KB, recorded minified 30,408 -> 30,492 B (+84 B), measured locally (linux) at 11,057 B brotli. `laneSeams` also re-derives a reader whose pass read a held node, so `isPending` of a memo parked behind a held one turns true. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "11.07 KB", + // A first answer landing in the tick a write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, F-9; 2026-10-30, over next @ 7135a77a): 11.07 KB -> 11.11 KB, recorded minified 30,492 -> 30,643 B (+151 B), measured locally (linux) at 11,094 B brotli. `read`'s pending path serves a first answer staged in the flush that re-asks it to a memo with no value (once, then the missed-wake latch re-runs it onto the re-ask), behind the verdict engine's hook; `isPending` of it reads the re-ask. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "11.11 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 30492, + capMinified: 30643, alias }, { @@ -3890,8 +3891,9 @@ module.exports = [ // 29.74 KB, recorded minified 88,302 -> 88,303 B; CI-measured at 29,727 B // (88,303 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "29.74 KB", - capMinified: 88303, + // A first answer landing in the tick a write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, F-9; 2026-10-30, over next @ 7135a77a): 29.74 KB -> 29.76 KB, recorded minified 88,303 -> 88,313 B (+10 B), measured locally (linux) at 29,746 B brotli. `handleAsync` lands a first answer a write dirtied (it resolved before the write): +10 B minified on every floor; the rest is brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "29.76 KB", + capMinified: 88313, alias: observeAlias }, // Compiled-template scenarios (2026-10-05): the four `app:` fixtures above @@ -4128,8 +4130,9 @@ module.exports = [ // 26.27 KB, recorded minified 79,985 -> 79,986 B; CI-measured at 26,257 B // (79,986 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "26.27 KB", - capMinified: 79986, + // A first answer landing in the tick a write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, F-9; 2026-10-30, over next @ 7135a77a): 26.27 KB -> 26.32 KB, recorded minified 79,986 -> 79,996 B (+10 B), measured locally (linux) at 26,308 B brotli. `handleAsync` lands a first answer a write dirtied (it resolved before the write): +10 B minified on every floor; the rest is brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "26.32 KB", + capMinified: 79996, alias }, { @@ -4301,8 +4304,9 @@ module.exports = [ // 32.43 KB, recorded minified 100,729 -> 100,748 B; CI-measured at 32,419 B // (100,748 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "32.43 KB", - capMinified: 100748, + // A first answer landing in the tick a write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, F-9; 2026-10-30, over next @ 7135a77a): 32.43 KB -> 32.48 KB, recorded minified 100,748 -> 100,758 B (+10 B), measured locally (linux) at 32,465 B brotli. `handleAsync` lands a first answer a write dirtied (it resolved before the write): +10 B minified on every floor; the rest is brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.48 KB", + capMinified: 100758, alias }, { @@ -5761,8 +5765,9 @@ module.exports = [ // 35.66 KB, recorded minified 107,955 -> 107,974 B; CI-measured at 35,645 B // (107,974 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. - limit: "35.66 KB", - capMinified: 107974, + // A first answer landing in the tick a write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, F-9; 2026-10-30, over next @ 7135a77a): 35.66 KB -> 35.68 KB, recorded minified 107,974 -> 107,984 B (+10 B), measured locally (linux) at 35,668 B brotli. `handleAsync` lands a first answer a write dirtied (it resolved before the write): +10 B minified on every floor; the rest is brotli layout. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "35.68 KB", + capMinified: 107984, alias: pageAlias }, { @@ -5904,11 +5909,12 @@ module.exports = [ // Re-record after the 2026-10-29 afternoon batch (#4418, #4414; 2026-10-29, over next @ 5d57e12a): 42.62 KB -> 42.65 KB, recorded minified 125,165 -> 125,266 B (+101 B), measured in CI at 42,640 B brotli. Growth is #4414 (a truncated fragment's fresh render computes what the server never sent: the truncation sweep deletes unsettled records at DOMContentLoaded, +~100 B minified in solid's hydration client); the maintainer said merge (2026-10-29). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record (2026-10-30, next @ 32163a9, size job 123956613767): cap unchanged, recorded minified 125,266 -> 125,285 B, measured in CI at 42,636 B brotli. Growth is the 2026-10-30 merges (#4424, #4436, #4416, #4442); #4416's truncation sweep is the +19 B minified on the hydrating scenarios. Cap at measured + 10 B rounded up to 0.01 KB where over; recorded minified = measured. // A held memo's parked reader re-derives at the seam (#4439, 2026-10-30, over next @ b249ad96): 42.65 KB -> 42.67 KB, recorded minified 125,285 -> 125,374 B (+89 B), measured locally (linux) at 42,655 B brotli. `laneSeams` also re-derives a reader whose pass read a held node, so `isPending` of a memo parked behind a held one turns true. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.67 KB", + // A first answer landing in the tick a write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, F-9; 2026-10-30, over next @ 7135a77a): 42.67 KB -> 42.82 KB, recorded minified 125,374 -> 125,525 B (+151 B), measured locally (linux) at 42,801 B brotli. `read`'s pending path serves a first answer staged in the flush that re-asks it to a memo with no value (once, then the missed-wake latch re-runs it onto the re-ask), behind the verdict engine's hook; `isPending` of it reads the re-ask. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.82 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125374, + capMinified: 125525, alias: pageAlias }, { From f7e2812536f57ede28edee4f04098a6171bc6bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:19:36 +0000 Subject: [PATCH 05/14] chore(size): raise the live page's frozen floor for the same-tick first answer 38.76 KB -> 38.79 KB, recorded minified 117,227 -> 117,379 B, measured at 38,778 B brotli. Size-Exception in the PR body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/floor-caps.json | 4 ++-- scripts/size/scenarios.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 8cc82c36de..4c6d8a5fd6 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -16,8 +16,8 @@ "minified": 101605 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.76 KB", - "minified": 117227 + "cap": "38.79 KB", + "minified": 117379 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.29 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index f01ceb8ce8..66317d13f0 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -5596,6 +5596,7 @@ module.exports = [ // (117,143 B minified). Lower only: cap at measured + 10 B rounded up to // 0.01 KB; recorded minified never raised. // A held memo's parked reader re-derives at the seam (#4439, 2026-10-30, over next @ b249ad96): 38.74 KB -> 38.76 KB, recorded minified 117,143 -> 117,227 B (+84 B), measured locally (linux) at 38,741 B brotli. `laneSeams` also re-derives a reader whose pass read a held node, so `isPending` of a memo parked behind a held one turns true. Correctness; a frozen floor, +15 B brotli is under the maintainer's 20 B flag line (2026-10-29), Size-Exception in the PR body. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // A first answer landing in the tick a plain write re-asks it is answered, then re-asked (D-22 amended 2026-10-30, over next @ 7135a77a): 38.76 KB -> 38.79 KB, recorded minified 117,227 -> 117,379 B (+152 B), measured locally (linux) at 38,778 B brotli. Correctness fix; a frozen floor, +37 B brotli, Size-Exception in the PR body. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], From da3234da3547076f673b988915ff7b277ea1fa0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:12:11 +0000 Subject: [PATCH 06/14] fix(signals): serve an answered-then-reasked first answer only to a reader waiting on it, off lanes, at any depth read()'s D-22 branch served the staged first answer to a reader that did not wait on it (a first pass: the seat table's D3 row predicts NR) and to a verdict lane's pass (V6: D-37, a lane sees the screen plus its own guesses). It now requires passLane === null and that the reader wait on the source a read of the node parks on (_error.source): the node itself one level down, the async it derives from at any depth, so identity(identity(m)) reads as m does (E2 at depth 2). The seat's D3' row says the same (!inSeat, the parked-on source, no staging of its own). handleAsync's answered-then-reasked guard is the verdict engine's (holdWorkHook): plain-only bundles keep the plain return. D-23 exception 1: an answered-then-reasked node is not a first load. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/docs/RULES-DERIVED.md | 2 +- packages/signals/src/core/async.ts | 7 +++-- packages/signals/src/core/core.ts | 29 +++++++++++-------- packages/signals/src/core/seat.ts | 9 ++++-- .../answered-then-reasked-same-tick.test.ts | 15 +++++++--- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 352455f508..6ba5a9e78f 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -188,7 +188,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 **From:** F-9, F-1. **Scenarios:** S-26, S-27. -`isPending(x)` is `true` while any of three causes holds: (i) a write held by a live hold; (ii) the node's own async in flight for a new question; (iii) a fresh value landed but held uncommitted by a hold it is entangled with — and `false` the moment none does. Exception 1: a node with no committed value — a first load, or a memo born held over a held value (D-2) — is loading, not pending. It has no verdict: inside an owner `isPending` of it does what its read does — a first load's `NotReadyError` goes to a boundary or holds the change (F-2); a reader of a memo born held is held with it, or caught by a boundary that has not shown content — and the reader runs at the commit, where the answer is final; unowned it is `false` (D-24). A node with a committed value is judged wherever its readers are: on screen, behind a fallback, or in a branch not yet attached. A verdict on content that may have no committed value is read inside the boundary that owns it — `

{m()}

{isPending(m) ? "updating" : ""}
` — where its reader is caught with the content; read outside, it holds the change that mounted it. Either way no answer is shown before the commit: `` is never seen enabled over an unjudged value (`packages/web/test/ispending-guard-born-held.spec.tsx`). No verdict is the honest answer: `true` would say "updating" for something never shown, and `false` would read as settled to a guard like `if (!isPending(x)) save(x())`. Exception 2: a re-ask of the same question is quiet; an `affects()` mark is a declared new question and pends exactly the marked data and what derives from it, released when the declaring hold settles (or at flush end, ambient). `[isPending(x), x()]` read in one scope never pairs `true` with the fresh value: a verdict reader sees the screen — in either read order — unless it is a node a hold's fetch is waiting on (D-25). `isPending(() => latest(x))` follows `x`'s own non-quiet async only — on a signal or sync memo the latest form is never pending — so `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value either. A spinner driven by `isPending` fires while the owner's async is still in flight (it does not wait for the hold). +`isPending(x)` is `true` while any of three causes holds: (i) a write held by a live hold; (ii) the node's own async in flight for a new question; (iii) a fresh value landed but held uncommitted by a hold it is entangled with — and `false` the moment none does. Exception 1: a node with no committed value — a first load, or a memo born held over a held value (D-2) — is loading, not pending. A node answered and then re-asked in the same flush — an async first answer that lands in the tick a write re-asks it — is not a first load (amended 2026-10-30): its answer is a produced value, so `latest` of it shows the answer and `isPending` of it is `true`, the re-ask being a new question; so is a derivation of it with no value of its own, at any depth (F-9, D-22). It has no verdict: inside an owner `isPending` of it does what its read does — a first load's `NotReadyError` goes to a boundary or holds the change (F-2); a reader of a memo born held is held with it, or caught by a boundary that has not shown content — and the reader runs at the commit, where the answer is final; unowned it is `false` (D-24). A node with a committed value is judged wherever its readers are: on screen, behind a fallback, or in a branch not yet attached. A verdict on content that may have no committed value is read inside the boundary that owns it — `

{m()}

{isPending(m) ? "updating" : ""}
` — where its reader is caught with the content; read outside, it holds the change that mounted it. Either way no answer is shown before the commit: `` is never seen enabled over an unjudged value (`packages/web/test/ispending-guard-born-held.spec.tsx`). No verdict is the honest answer: `true` would say "updating" for something never shown, and `false` would read as settled to a guard like `if (!isPending(x)) save(x())`. Exception 2: a re-ask of the same question is quiet; an `affects()` mark is a declared new question and pends exactly the marked data and what derives from it, released when the declaring hold settles (or at flush end, ambient). `[isPending(x), x()]` read in one scope never pairs `true` with the fresh value: a verdict reader sees the screen — in either read order — unless it is a node a hold's fetch is waiting on (D-25). `isPending(() => latest(x))` follows `x`'s own non-quiet async only — on a signal or sync memo the latest form is never pending — so `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value either. A spinner driven by `isPending` fires while the owner's async is still in flight (it does not wait for the hold). **Collapses:** [A19](./SPEC-ASYNC-SEMANTICS.md#a19-ispendingx--the-observable-value-is-not-final-three-causes), [A24](./SPEC-ASYNC-SEMANTICS.md#a24-question-scoped-pending-pending-iff-a-value-change-is-in-flight-or-an-affects-mark-is-live), [A10](./SPEC-ASYNC-SEMANTICS.md#a10-ispendingx-x-is-atomic-within-one-scope), [A8](./SPEC-ASYNC-SEMANTICS.md#a8-ispending--latestx-follows-xs-own-async-only--verdicts-are-per-channel), [A14](./SPEC-ASYNC-SEMANTICS.md#a14-companion-nodes-get-child-lanes-that-do-not-merge-with-the-owner), [A20](./SPEC-ASYNC-SEMANTICS.md#a20-superseded-optimistic-writes-announce-a-store-wide-pending)/[A21](./SPEC-ASYNC-SEMANTICS.md#a21-superseded-the-store-wide-mask) (superseded — the mask is gone), recovery 6.3. **Status:** standing. A probe alone holds nothing; a plain read beside it holds (`optimistic-settle-verdicts.test.ts` #3409, `question-scoped-pending.test.ts` 3.4; #3977). `isPending` of a memo born held, read inside and outside the boundary: `tests/fresh-boundary-born-held-reader.test.ts` (#3977; the engine joins the reader to the hold as the read does, which is the throw's observable effect). diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 5a40894c0b..b116647d1b 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -552,8 +552,11 @@ export function handleAsync( // with the new value, creating a fresh Promise that supersedes this one. // Not a first answer (D-22, F-9): it resolved before the write — only its // reaction ran after — so it is a produced value, and the flush that - // re-asks lands it first (the node is answered, then re-asked). - if (el._flags & REACTIVE_DIRTY && !(el._statusFlags & STATUS_UNINITIALIZED)) return; + // re-asks lands it first (the node is answered, then re-asked). Only + // `latest`/`isPending` tell such a node from one with no value, so the + // landing is the verdict engine's (`holdWorkHook`). + if (el._flags & REACTIVE_DIRTY && !(holdWorkHook && el._statusFlags & STATUS_UNINITIALIZED)) + return; const wasUninitialized = el._statusFlags & STATUS_UNINITIALIZED; landStatus(el); // Attribution hook: lets the engine snapshot state before the landing diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 8f4a7ed9b6..2b12b70d2a 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2178,26 +2178,31 @@ export function read(el: Signal | Computed): T { // D-22 (amended 2026-10-30), F-9: `el`'s first value was staged // this flush and `el` went pending on a re-ask in it — an async // first answer landing in the tick a write re-asks it: answered, - // then re-asked. A memo with no value, waiting on `el`, derives from - // that value, as a flush of the answer alone would have let it, - // then runs again (the missed-wake latch) and waits on the re-ask - // holding it: `latest`/`isPending` of it read as of `el`. One pass: - // every read of it is served (the latch marks it); its wait on `el` - // is dropped, so the re-run throws and waits on `el` afresh. Not a - // render effect (the frame: its plain read throws), lane work, or a - // held node (the staging is the hold's). A plain read of such a memo - // throws as one of a memo with no value; only `latest`/`isPending` - // tell them apart, so it is the verdict engine's. + // then re-asked. A memo with no value, waiting on `el` (on the + // source a read of `el` parks on: `el` itself, or the async it + // derives from — at any depth), derives from that value, as a + // flush of the answer alone would have let it, then runs again (the + // missed-wake latch) and waits on the re-ask holding it: + // `latest`/`isPending` of it read as of `el`. One pass: every read + // of it is served (the latch marks it); its wait on `el` is + // dropped, so the re-run throws and waits on `el` afresh. Not a + // reader that does not wait on `el` (a first pass: no first load + // to have been answered), a render effect (the frame: its plain + // read throws), lane work or a verdict lane's pass (D-37: the + // screen plus the lane's own guesses), or a held node (the staging + // is the hold's). A plain read of such a memo throws as one of a + // memo with no value; only `latest`/`isPending` tell them apart, so + // it is the verdict engine's. if ( observeFlightHook && + passLane === null && (c as Computed)._statusFlags & owner._statusFlags & STATUS_UNINITIALIZED && el._pendingValue !== NOT_PENDING && !(el._config & (CONFIG_HELD | CONFIG_STAGED_ERROR)) && !(c as any)._type && (c._flags & REACTIVE_MISSED_WAKE || ((c as Computed)._pendingValue === NOT_PENDING && - (!((c as Computed)._statusFlags & STATUS_PENDING) || - (c as Computed)._x?._pendingSources?.delete(owner)) && + (c as Computed)._x?._pendingSources?.delete(owner._x!._error.source) && (c._flags |= REACTIVE_MISSED_WAKE))) ) { if (__TEST__) shadowActual(SLOT_P); diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index dc77dd3b07..28fd90c0d0 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -918,7 +918,9 @@ export function slot( } // D-22 (amended 2026-10-30), F-9: a first answer staged this flush and // re-asked in it — answered, then re-asked. A pass with no value of its - // own derives from it, once (`read`). + // own, waiting on it (on the source a read of it parks on — the node, + // or the async it derives from, at any depth), derives from it, once + // (`read`). Not lane work or a verdict lane's pass (D-37). if ( observeFlightHook && cUninit && @@ -927,7 +929,10 @@ export function slot( !held && !stagedError && !isEffect && - (c!._flags & REACTIVE_MISSED_WAKE || c!._x?._pendingSources?.has(el as Computed)) + !inSeat && + (c!._flags & REACTIVE_MISSED_WAKE || + (c!._pendingValue === NOT_PENDING && + !!c!._x?._pendingSources?.has((el as Computed)._x!._error.source))) ) return carry(pred(rowBase + "D3'", SLOT_P)); return carry(pred(leaf ? "L5" : rowBase + "D3", SLOT_NR)); // F-2 D-28 / F-2 D-9 diff --git a/packages/signals/tests/answered-then-reasked-same-tick.test.ts b/packages/signals/tests/answered-then-reasked-same-tick.test.ts index 9f3d2679ee..cbff9c9452 100644 --- a/packages/signals/tests/answered-then-reasked-same-tick.test.ts +++ b/packages/signals/tests/answered-then-reasked-same-tick.test.ts @@ -17,10 +17,14 @@ import { // flush. The answer exists from when it resolved: the memo is answered, then // re-asked — `latest` shows the answer, the re-ask is a new question // (`isPending` true), and a derivation of the memo reads the same (async -// generator E2/E3, `t S5 s0=2 t` and `t S6 s0=0 t`). +// generator E2/E3, `t S5 s0=2 t` and `t S6 s0=0 t`) — at every depth: an +// identity memo of an identity memo reads as the memo does (D-22). const task = () => new Promise(r => setTimeout(r, 0)); -async function sameTick(view: "memo" | "identity" | "store", probe: "pending" | "latest") { +async function sameTick( + view: "memo" | "identity" | "identity2" | "store", + probe: "pending" | "latest" +) { const asks: { v: number; resolve: (v: number) => void }[] = []; const [s, setS] = createSignal(1); const m = createMemo(() => { @@ -29,7 +33,10 @@ async function sameTick(view: "memo" | "identity" | "store", probe: "pending" | }); let x: () => number = m; if (view === "identity") x = createMemo(() => m()); - else if (view === "store") { + else if (view === "identity2") { + const i = createMemo(() => m()); + x = createMemo(() => i()); + } else if (view === "store") { const [st] = createStore<{ v?: number }>(d => { d.v = m(); }, {}); @@ -70,7 +77,7 @@ async function sameTick(view: "memo" | "identity" | "store", probe: "pending" | } describe("a first answer landing in the tick a plain write re-asks it", () => { - for (const view of ["memo", "identity", "store"] as const) { + for (const view of ["memo", "identity", "identity2", "store"] as const) { it(`${view}: isPending is true — the re-ask is a new question (F-9)`, async () => { expect(await sameTick(view, "pending")).toBe("p:true"); }); From 8c98e578cdc0689109e31e3dcb9f7f9d1a88aaae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:13:33 +0000 Subject: [PATCH 07/14] fix(signals): type the parked-on source read in read() and the D3' row Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/core.ts | 4 +++- packages/signals/src/core/seat.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 2b12b70d2a..b4af45f7ef 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2202,7 +2202,9 @@ export function read(el: Signal | Computed): T { !(c as any)._type && (c._flags & REACTIVE_MISSED_WAKE || ((c as Computed)._pendingValue === NOT_PENDING && - (c as Computed)._x?._pendingSources?.delete(owner._x!._error.source) && + (c as Computed)._x?._pendingSources?.delete( + (owner._x!._error as NotReadyError).source + ) && (c._flags |= REACTIVE_MISSED_WAKE))) ) { if (__TEST__) shadowActual(SLOT_P); diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index 28fd90c0d0..75727ee28a 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -932,7 +932,7 @@ export function slot( !inSeat && (c!._flags & REACTIVE_MISSED_WAKE || (c!._pendingValue === NOT_PENDING && - !!c!._x?._pendingSources?.has((el as Computed)._x!._error.source))) + !!c!._x?._pendingSources?.has(((el as Computed)._x!._error as any).source))) ) return carry(pred(rowBase + "D3'", SLOT_P)); return carry(pred(leaf ? "L5" : rowBase + "D3", SLOT_NR)); // F-2 D-28 / F-2 D-9 From 46ae1751b38702e9de404b430d74f4c9bb93f99e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:17:59 +0000 Subject: [PATCH 08/14] chore(size): re-record #4459's caps over next @ 131ceb8 isPending/latest, compiled live SC and live + router grow with the verdict-engine branch (+159..+161 B minified); no frozen floor moves: plain-only bundles are unchanged in minified bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/scenarios.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 99c9d6c86f..90db904e5f 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1443,9 +1443,10 @@ module.exports = [ // A probe beside `latest()` outside any Loading no longer loops on a refetch (F-6, D-23; 2026-10-30, over next @ 7135a77a): 11.07 KB -> 11.10 KB, recorded minified 30,492 -> 30,569 B (+77 B), measured locally (linux) at 11,085 B brotli. `pendingVerdict` remembers the transaction a `latest` read of an unheld flight opened, and a probe in it joins the verdict lane without asking to re-run at its landing (the re-run re-opened it every flush). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 30,569 -> 30,596 B (+27 B), measured locally (linux) at 11,092 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 11.10 KB -> 11.12 KB, recorded minified 30,596 -> 30,641 B (+45 B), measured locally (linux) at 11,109 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "11.12 KB", + // A first answer landing in the tick a write re-asks it is answered, then re-asked (#4459, D-22 amended 2026-10-30, F-9; over next @ 131ceb8): 11.12 KB -> 11.17 KB, recorded minified 30,641 -> 30,801 B (+160 B), measured locally (linux) at 11,152 B brotli. `read`'s pending path serves the staged first answer to a reader with no value waiting on it (at any depth, off lanes), and `handleAsync` lands it, both behind the verdict engine: plain-only bundles are unchanged in minified bytes. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "11.17 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 30641, + capMinified: 30801, alias }, { @@ -5927,11 +5928,12 @@ module.exports = [ // A probe beside `latest()` outside any Loading no longer loops on a refetch (F-6, D-23; 2026-10-30, over next @ 7135a77a): 42.67 KB -> 42.71 KB, recorded minified 125,374 -> 125,450 B (+76 B), measured locally (linux) at 42,693 B brotli. `pendingVerdict` remembers the transaction a `latest` read of an unheld flight opened, and a probe in it joins the verdict lane without asking to re-run at its landing (the re-run re-opened it every flush). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 125,450 -> 125,478 B (+28 B), measured locally (linux) at 42,695 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 42.71 KB -> 42.73 KB, recorded minified 125,478 -> 125,518 B (+40 B), measured locally (linux) at 42,715 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.73 KB", + // A first answer landing in the tick a write re-asks it is answered, then re-asked (#4459, D-22 amended 2026-10-30, F-9; over next @ 131ceb8): 42.73 KB -> 42.74 KB, recorded minified 125,518 -> 125,677 B (+159 B), measured locally (linux) at 42,729 B brotli. `read`'s pending path serves the staged first answer to a reader with no value waiting on it (at any depth, off lanes), and `handleAsync` lands it, both behind the verdict engine: plain-only bundles are unchanged in minified bytes. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.74 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125518, + capMinified: 125677, alias: pageAlias }, { @@ -6332,11 +6334,12 @@ module.exports = [ // A probe beside `latest()` outside any Loading no longer loops on a refetch (F-6, D-23; 2026-10-30, over next @ 7135a77a): 48.42 KB -> 48.44 KB, recorded minified 142,631 -> 142,706 B (+75 B), measured locally (linux) at 48,421 B brotli. `pendingVerdict` remembers the transaction a `latest` read of an unheld flight opened, and a probe in it joins the verdict lane without asking to re-run at its landing (the re-run re-opened it every flush). Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 142,706 -> 142,735 B (+29 B), measured locally (linux) at 48,365 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): cap unchanged at 48.44 KB, recorded minified 142,735 -> 142,780 B (+45 B), measured locally (linux) at 48,394 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // A first answer landing in the tick a write re-asks it is answered, then re-asked (#4459, D-22 amended 2026-10-30, F-9; over next @ 131ceb8): 48.44 KB -> 48.44 KB, recorded minified 142,780 -> 142,941 B (+161 B), measured locally (linux) at 48,430 B brotli. `read`'s pending path serves the staged first answer to a reader with no value waiting on it (at any depth, off lanes), and `handleAsync` lands it, both behind the verdict engine: plain-only bundles are unchanged in minified bytes. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: "48.44 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,509 B (+58 B minified over next's record), measured locally (linux) at 47,516 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 140,463 -> 140,485 B (+22 B minified over next's record), measured locally (linux) at 47,547 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 140,485 -> 140,983 B (+405 B minified over next), measured locally (linux) at 47,722 B brotli (+162 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 142780, + capMinified: 142941, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From 0c06ffc1d5b1ba15d1d68270c89ea385ce8dd897 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:41:08 +0000 Subject: [PATCH 09/14] chore(size): re-record the live page floor's minified for #4459 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 2a42d8a491..339b79ded6 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -17,7 +17,7 @@ }, "page: live server components (base + live/GET + action + isPending/latest)": { "cap": "38.85 KB", - "minified": 117376 + "minified": 117538 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.29 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 90db904e5f..8a01fccec6 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -5612,6 +5612,7 @@ module.exports = [ // A held memo's parked reader re-derives at the seam (#4439, 2026-10-30, over next @ b249ad96): 38.74 KB -> 38.76 KB, recorded minified 117,143 -> 117,227 B (+84 B), measured locally (linux) at 38,741 B brotli. `laneSeams` also re-derives a reader whose pass read a held node, so `isPending` of a memo parked behind a held one turns true. Correctness; a frozen floor, +15 B brotli is under the maintainer's 20 B flag line (2026-10-29), Size-Exception in the PR body. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold reads it whichever flush runs it (#4426, D-25/D-27; re-measured over next @ 9c4e4a8): 38.81 KB -> 38.83 KB, recorded minified 117,251 -> 117,331 B, measured locally (linux) at 38,817 B brotli. `stagedScreen`'s held-node check (+20 B minified in core.js) on top of next's +13 B over this floor's record (#4452); the brotli is layout. Correctness; a frozen floor, Size-Exception in the PR body. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 38.83 KB -> 38.85 KB, recorded minified 117,331 -> 117,376 B (+45 B), measured locally (linux) at 38,839 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // An answered-then-reasked first answer is served to the reader waiting on it, off lanes, at any depth (#4459, 2026-10-30, over next @ 131ceb8c): cap unchanged at 38.85 KB, recorded minified 117,376 -> 117,538 B (+162 B), measured locally (linux) at 38,813 B brotli. `read()` serves the staging to a reader parked on the source the read parks on (D-22, D-23 exception 1, F-9), with the verdict engine alone. Correctness; minified re-recorded so the next PR over this floor is gated on its own bytes, Size-Exception in the PR body. Recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], From 37d60d1e81fc668f850a9666f0ad6988fc42e56a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:03:22 +0000 Subject: [PATCH 10/14] fix(signals): serve an answered-then-reasked first answer one pass a flush, out of line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read()'s D-22 arm served a reader whose pass then threw anyway (a read beside the served one parked on the same source): the throw re-parked it on that source, the missed-wake latch re-ran it, and the re-run qualified again — a synchronous loop within one flush (async-gen 91501 #749, minimized: a boundary's children memo over two Shows that both park on the re-asked memo). The arm now stamps the served pass's clock on the reader (`_x._served`) and serves a reader once a flush; the re-run throws and waits. The seat's D3' row says the same. The arm moves out of read() into `servesStaged`, called behind `observeFlightHook` on the pending-throw path, so read() itself grows by a call (bytecode 1318 -> 1170; next 1139). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/core.ts | 72 ++++++++++--------- packages/signals/src/core/seat.ts | 5 +- packages/signals/src/core/types.ts | 3 + .../reask-served-once-a-flush.spec.ts | 56 +++++++++++++++ scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 16 +++-- 6 files changed, 113 insertions(+), 41 deletions(-) create mode 100644 packages/web/test/async-gen/reask-served-once-a-flush.spec.ts diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index b4af45f7ef..26cdcf2eae 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1960,6 +1960,42 @@ export function devRead(el: Signal | Computed): void { ); } +/** + * D-22 (amended 2026-10-30), F-9: `el`'s first value was staged this flush + * and `el` (`owner`) went pending on a re-ask in it — an async first answer + * landing in the tick a write re-asks it: answered, then re-asked. A memo + * `c` with no value, waiting on `el` (on the source a read of `el` parks on: + * `el` itself, or the async it derives from — at any depth), derives from + * that value, as a flush of the answer alone would have let it, then runs + * again (the missed-wake latch) and waits on the re-ask holding it: + * `latest`/`isPending` of it read as of `el`. One pass: every read of it is + * served (the latch marks it); its wait on `el` is dropped, so the re-run + * throws and waits on `el` afresh. One served pass a flush (`_served`): a + * pass that throws anyway, on a read beside it, parks on `el` again, and its + * re-run is not served — served, it would re-run without end. Not a reader + * that does not wait on `el` (a first pass: no first load to have been + * answered), a render effect (the frame: its plain read throws), lane work + * or a verdict lane's pass (D-37: the screen plus the lane's own guesses), + * or a held node (the staging is the hold's). A plain read of such a memo + * throws as one of a memo with no value; only `latest`/`isPending` tell them + * apart, so it is the verdict engine's (`read` calls it behind + * `observeFlightHook`, on the path where a pending read throws). + */ +function servesStaged(c: Computed, el: Signal | Computed, owner: Computed) { + return ( + passLane === null && + c._statusFlags & owner._statusFlags & STATUS_UNINITIALIZED && + el._pendingValue !== NOT_PENDING && + !(el._config & (CONFIG_HELD | CONFIG_STAGED_ERROR)) && + !(c as any)._type && + (c._flags & REACTIVE_MISSED_WAKE || + (c._pendingValue === NOT_PENDING && + c._x?._served !== clock && + c._x?._pendingSources?.delete((owner._x!._error as NotReadyError).source) && + ((c._x!._served = clock), (c._flags |= REACTIVE_MISSED_WAKE)))) + ); +} + export function read(el: Signal | Computed): T { // Shadow (S0): every read is run through `shadowRead`, which re-enters // here once (the bypass) and compares the slot the table predicts with @@ -2175,38 +2211,10 @@ export function read(el: Signal | Computed): T { ); } if (!(c._config & CONFIG_CHILDREN_FORBIDDEN) || owner._statusFlags & STATUS_UNINITIALIZED) { - // D-22 (amended 2026-10-30), F-9: `el`'s first value was staged - // this flush and `el` went pending on a re-ask in it — an async - // first answer landing in the tick a write re-asks it: answered, - // then re-asked. A memo with no value, waiting on `el` (on the - // source a read of `el` parks on: `el` itself, or the async it - // derives from — at any depth), derives from that value, as a - // flush of the answer alone would have let it, then runs again (the - // missed-wake latch) and waits on the re-ask holding it: - // `latest`/`isPending` of it read as of `el`. One pass: every read - // of it is served (the latch marks it); its wait on `el` is - // dropped, so the re-run throws and waits on `el` afresh. Not a - // reader that does not wait on `el` (a first pass: no first load - // to have been answered), a render effect (the frame: its plain - // read throws), lane work or a verdict lane's pass (D-37: the - // screen plus the lane's own guesses), or a held node (the staging - // is the hold's). A plain read of such a memo throws as one of a - // memo with no value; only `latest`/`isPending` tell them apart, so - // it is the verdict engine's. - if ( - observeFlightHook && - passLane === null && - (c as Computed)._statusFlags & owner._statusFlags & STATUS_UNINITIALIZED && - el._pendingValue !== NOT_PENDING && - !(el._config & (CONFIG_HELD | CONFIG_STAGED_ERROR)) && - !(c as any)._type && - (c._flags & REACTIVE_MISSED_WAKE || - ((c as Computed)._pendingValue === NOT_PENDING && - (c as Computed)._x?._pendingSources?.delete( - (owner._x!._error as NotReadyError).source - ) && - (c._flags |= REACTIVE_MISSED_WAKE))) - ) { + // D-22 (amended 2026-10-30), F-9: an answered-then-reasked first + // answer (`servesStaged`), the verdict engine's — out of line, so + // `read` gains a call only where a pending read would throw. + if (observeFlightHook && servesStaged(c as Computed, el, owner)) { if (__TEST__) shadowActual(SLOT_P); return el._pendingValue as T; } diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index 7b30f363bc..ee0f465b4e 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -919,8 +919,8 @@ export function slot( // D-22 (amended 2026-10-30), F-9: a first answer staged this flush and // re-asked in it — answered, then re-asked. A pass with no value of its // own, waiting on it (on the source a read of it parks on — the node, - // or the async it derives from, at any depth), derives from it, once - // (`read`). Not lane work or a verdict lane's pass (D-37). + // or the async it derives from, at any depth), derives from it, once a + // flush (`read`). Not lane work or a verdict lane's pass (D-37). if ( observeFlightHook && cUninit && @@ -932,6 +932,7 @@ export function slot( !inSeat && (c!._flags & REACTIVE_MISSED_WAKE || (c!._pendingValue === NOT_PENDING && + c!._x?._served !== clock && !!c!._x?._pendingSources?.has(((el as Computed)._x!._error as any).source))) ) return carry(pred(rowBase + "D3'", SLOT_P)); diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 4012b192f6..3c15060f04 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -86,6 +86,9 @@ export interface NodeExtension { _error: unknown; _blocked: boolean | undefined; _pendingSources: Set> | undefined; + /** The clock of this node's pass served an answered-then-reasked first + * answer (D-22, F-9; `read`): one served pass a flush. */ + _served?: number; _unobserved: (() => void) | undefined; _snapshotValue: any; /** L2 — the previous pass's frame, parked for this node's commit: its child diff --git a/packages/web/test/async-gen/reask-served-once-a-flush.spec.ts b/packages/web/test/async-gen/reask-served-once-a-flush.spec.ts new file mode 100644 index 0000000000..a3a3826f32 --- /dev/null +++ b/packages/web/test/async-gen/reask-served-once-a-flush.spec.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ +// async-gen 91501 #749, minimized (#4459 follow-up; D-22 amended 2026-10-30, +// F-9): `m0` is answered, then re-asked in one flush, and a boundary's +// children memo reads two `Show`s that both park on `m0`. Its first read of +// the first `Show`'s output is served the answer; its read of the second +// throws, so the pass parks on `m0` again with no value of its own. Served +// on every re-run, the missed-wake latch re-ran it in the same flush without +// end (a synchronous loop). One served pass a flush: the re-run throws and +// waits on `m0`. +import { expect, test } from "vitest"; +import { runScenario } from "./run.js"; +import type { Scenario } from "./scenario.js"; + +// [s0=3 m0=async(s0 !%4=2) m1=async(m0 !%4=1)] +// +// :: s0=2 S1 t S0 s0=1 +const sc: Scenario = { + signals: [3], + memos: [ + { async: true, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: 2 }, + { async: true, deps: [{ src: { k: "m", i: 0 }, how: "plain" }], poison: 1 } + ], + tree: { + t: "group", + kids: [ + { + t: "show", + when: { how: "plain", src: { k: "m", i: 1 } }, + child: { t: "leaf", text: [], attrs: [], grouped: false }, + fallback: true + }, + { + t: "show", + when: { how: "plain", src: { k: "m", i: 0 } }, + child: { t: "leaf", text: [], attrs: [], grouped: false }, + fallback: true + } + ] + }, + steps: [ + { t: "write", s: 0, v: 2 }, + { t: "settle", pick: 1 }, + { t: "task" }, + { t: "settle", pick: 0 }, + { t: "write", s: 0, v: 1 } + ] +}; + +test("a reader served an answered-then-reasked first answer is served one pass a flush", async () => { + for (const variant of [{}, { identity: true }, { store: true }, { split: true }]) { + const r = await runScenario(sc, variant); + expect(r.screens, JSON.stringify(variant)).toEqual(Array(6).fill("[ROOT-L]")); + } +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 339b79ded6..f519b7c3e1 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -17,7 +17,7 @@ }, "page: live server components (base + live/GET + action + isPending/latest)": { "cap": "38.85 KB", - "minified": 117538 + "minified": 117611 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.29 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 979ea63d11..ea71b59c07 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1444,9 +1444,10 @@ module.exports = [ // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 30,569 -> 30,596 B (+27 B), measured locally (linux) at 11,092 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 11.10 KB -> 11.12 KB, recorded minified 30,596 -> 30,641 B (+45 B), measured locally (linux) at 11,109 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A first answer landing in the tick a write re-asks it is answered, then re-asked (#4459, D-22 amended 2026-10-30, F-9; over next @ 131ceb8): 11.12 KB -> 11.17 KB, recorded minified 30,641 -> 30,801 B (+160 B), measured locally (linux) at 11,152 B brotli. `read`'s pending path serves the staged first answer to a reader with no value waiting on it (at any depth, off lanes), and `handleAsync` lands it, both behind the verdict engine: plain-only bundles are unchanged in minified bytes. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "11.17 KB", + // One served pass a flush for an answered-then-reasked first answer, out of line in read (#4459 follow-up, D-22, F-9; over #4459 @ a083918be): 11.17 KB -> 11.19 KB, recorded minified 30,801 -> 30,876 (+75 B), measured locally (linux) at 11,180 B brotli. A reader whose served pass throws on a read beside it is not served again in that flush (the latch re-ran it without end); the arm moves out of read into servesStaged. Verdict engine only. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "11.19 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 30801, + capMinified: 30876, alias }, { @@ -5614,6 +5615,7 @@ module.exports = [ // A verdict reader born into a hold reads it whichever flush runs it (#4426, D-25/D-27; re-measured over next @ 9c4e4a8): 38.81 KB -> 38.83 KB, recorded minified 117,251 -> 117,331 B, measured locally (linux) at 38,817 B brotli. `stagedScreen`'s held-node check (+20 B minified in core.js) on top of next's +13 B over this floor's record (#4452); the brotli is layout. Correctness; a frozen floor, Size-Exception in the PR body. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 38.83 KB -> 38.85 KB, recorded minified 117,331 -> 117,376 B (+45 B), measured locally (linux) at 38,839 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // An answered-then-reasked first answer is served to the reader waiting on it, off lanes, at any depth (#4459, 2026-10-30, over next @ 131ceb8c): cap unchanged at 38.85 KB, recorded minified 117,376 -> 117,538 B (+162 B), measured locally (linux) at 38,813 B brotli. `read()` serves the staging to a reader parked on the source the read parks on (D-22, D-23 exception 1, F-9), with the verdict engine alone. Correctness; minified re-recorded so the next PR over this floor is gated on its own bytes, Size-Exception in the PR body. Recorded minified = measured. + // One served pass a flush for an answered-then-reasked first answer, out of line in read (#4459 follow-up, D-22, F-9; over #4459 @ a083918be): cap unchanged at 38.85 KB, recorded minified 117,538 -> 117,611 (+73 B), measured locally (linux) at 38,872 B brotli. A reader whose served pass throws on a read beside it is not served again in that flush (the latch re-ran it without end); the arm moves out of read into servesStaged. Verdict engine only. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5931,11 +5933,12 @@ module.exports = [ // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 125,450 -> 125,478 B (+28 B), measured locally (linux) at 42,695 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 42.71 KB -> 42.73 KB, recorded minified 125,478 -> 125,518 B (+40 B), measured locally (linux) at 42,715 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A first answer landing in the tick a write re-asks it is answered, then re-asked (#4459, D-22 amended 2026-10-30, F-9; over next @ 131ceb8): 42.73 KB -> 42.74 KB, recorded minified 125,518 -> 125,677 B (+159 B), measured locally (linux) at 42,729 B brotli. `read`'s pending path serves the staged first answer to a reader with no value waiting on it (at any depth, off lanes), and `handleAsync` lands it, both behind the verdict engine: plain-only bundles are unchanged in minified bytes. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.74 KB", + // One served pass a flush for an answered-then-reasked first answer, out of line in read (#4459 follow-up, D-22, F-9; over #4459 @ a083918be): 42.74 KB -> 42.80 KB, recorded minified 125,677 -> 125,750 (+73 B), measured locally (linux) at 42,785 B brotli. A reader whose served pass throws on a read beside it is not served again in that flush (the latch re-ran it without end); the arm moves out of read into servesStaged. Verdict engine only. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.80 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125677, + capMinified: 125750, alias: pageAlias }, { @@ -6338,11 +6341,12 @@ module.exports = [ // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 142,706 -> 142,735 B (+29 B), measured locally (linux) at 48,365 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): cap unchanged at 48.44 KB, recorded minified 142,735 -> 142,780 B (+45 B), measured locally (linux) at 48,394 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A first answer landing in the tick a write re-asks it is answered, then re-asked (#4459, D-22 amended 2026-10-30, F-9; over next @ 131ceb8): 48.44 KB -> 48.44 KB, recorded minified 142,780 -> 142,941 B (+161 B), measured locally (linux) at 48,430 B brotli. `read`'s pending path serves the staged first answer to a reader with no value waiting on it (at any depth, off lanes), and `handleAsync` lands it, both behind the verdict engine: plain-only bundles are unchanged in minified bytes. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "48.44 KB", + // One served pass a flush for an answered-then-reasked first answer, out of line in read (#4459 follow-up, D-22, F-9; over #4459 @ a083918be): 48.44 KB -> 48.46 KB, recorded minified 142,941 -> 143,014 (+73 B), measured locally (linux) at 48,442 B brotli. A reader whose served pass throws on a read beside it is not served again in that flush (the latch re-ran it without end); the arm moves out of read into servesStaged. Verdict engine only. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "48.46 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,509 B (+58 B minified over next's record), measured locally (linux) at 47,516 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 140,463 -> 140,485 B (+22 B minified over next's record), measured locally (linux) at 47,547 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 140,485 -> 140,983 B (+405 B minified over next), measured locally (linux) at 47,722 B brotli (+162 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 142941, + capMinified: 143014, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From 44cdb35e8eb048b9364d395983a677391c940e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:17:28 +0000 Subject: [PATCH 11/14] fix(signals): an answered-then-reasked first answer reaches a reader its landing released servesStaged served a reader with no value only while it still waited on the source. When the memo's input is read through latest() or a sync memo, the write's re-ask reaches the memo after its landing ran, and the landing had already released the identity memo's wait: it ran with no value, waiting on nothing, and threw, so latest(identity(m)) and a derived store stayed on [ROOT-L] while latest(m) showed the answer (async-gen E2/E3 3289 #310, 91501 #1993, #2609). D-22 as amended says a derivation with no value of its own reads like the memo at any depth; which the flush ran first is not the reader's. A reader that is not pending is served too (one served pass a flush, as before); one pending on another source is not. The seat's D3' row says the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/core.ts | 18 ++-- packages/signals/src/core/seat.ts | 8 +- ...wered-then-reasked-released-reader.test.ts | 84 +++++++++++++++++++ .../test/async-gen/e2-released-reader.spec.ts | 28 +++++++ 4 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 packages/signals/tests/answered-then-reasked-released-reader.test.ts create mode 100644 packages/web/test/async-gen/e2-released-reader.spec.ts diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 26cdcf2eae..c8a06b119a 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1964,17 +1964,20 @@ export function devRead(el: Signal | Computed): void { * D-22 (amended 2026-10-30), F-9: `el`'s first value was staged this flush * and `el` (`owner`) went pending on a re-ask in it — an async first answer * landing in the tick a write re-asks it: answered, then re-asked. A memo - * `c` with no value, waiting on `el` (on the source a read of `el` parks on: - * `el` itself, or the async it derives from — at any depth), derives from - * that value, as a flush of the answer alone would have let it, then runs + * `c` with no value — waiting on `el` (on the source a read of `el` parks + * on: `el` itself, or the async it derives from — at any depth), or not + * waiting at all (its wait released by the landing, or a first pass) — + * derives from that value, as a flush of the answer alone would have let it + * (whether the landing ran before the re-ask reached `c` is the flush's + * order, not the reader's: D-22, at any depth), then runs * again (the missed-wake latch) and waits on the re-ask holding it: * `latest`/`isPending` of it read as of `el`. One pass: every read of it is * served (the latch marks it); its wait on `el` is dropped, so the re-run * throws and waits on `el` afresh. One served pass a flush (`_served`): a * pass that throws anyway, on a read beside it, parks on `el` again, and its * re-run is not served — served, it would re-run without end. Not a reader - * that does not wait on `el` (a first pass: no first load to have been - * answered), a render effect (the frame: its plain read throws), lane work + * waiting on another source, a render effect (the frame: its plain read + * throws), lane work * or a verdict lane's pass (D-37: the screen plus the lane's own guesses), * or a held node (the staging is the hold's). A plain read of such a memo * throws as one of a memo with no value; only `latest`/`isPending` tell them @@ -1991,8 +1994,9 @@ function servesStaged(c: Computed, el: Signal | Computed, owner: (c._flags & REACTIVE_MISSED_WAKE || (c._pendingValue === NOT_PENDING && c._x?._served !== clock && - c._x?._pendingSources?.delete((owner._x!._error as NotReadyError).source) && - ((c._x!._served = clock), (c._flags |= REACTIVE_MISSED_WAKE)))) + (!(c._statusFlags & STATUS_PENDING) || + c._x?._pendingSources?.delete((owner._x!._error as NotReadyError).source)) && + ((ext(c)._served = clock), (c._flags |= REACTIVE_MISSED_WAKE)))) ); } diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index ee0f465b4e..352805026c 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -919,8 +919,9 @@ export function slot( // D-22 (amended 2026-10-30), F-9: a first answer staged this flush and // re-asked in it — answered, then re-asked. A pass with no value of its // own, waiting on it (on the source a read of it parks on — the node, - // or the async it derives from, at any depth), derives from it, once a - // flush (`read`). Not lane work or a verdict lane's pass (D-37). + // or the async it derives from, at any depth) or not waiting at all (its + // wait released by the landing, or a first pass), derives from it, once + // a flush (`read`). Not lane work or a verdict lane's pass (D-37). if ( observeFlightHook && cUninit && @@ -933,7 +934,8 @@ export function slot( (c!._flags & REACTIVE_MISSED_WAKE || (c!._pendingValue === NOT_PENDING && c!._x?._served !== clock && - !!c!._x?._pendingSources?.has(((el as Computed)._x!._error as any).source))) + (!(c!._statusFlags & STATUS_PENDING) || + !!c!._x?._pendingSources?.has(((el as Computed)._x!._error as any).source)))) ) return carry(pred(rowBase + "D3'", SLOT_P)); return carry(pred(leaf ? "L5" : rowBase + "D3", SLOT_NR)); // F-2 D-28 / F-2 D-9 diff --git a/packages/signals/tests/answered-then-reasked-released-reader.test.ts b/packages/signals/tests/answered-then-reasked-released-reader.test.ts new file mode 100644 index 0000000000..a6494e9591 --- /dev/null +++ b/packages/signals/tests/answered-then-reasked-released-reader.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + latest +} from "../src/index.js"; + +// D-22 (amended 2026-10-30), F-9, async generator E2 (3289 #310, 91501 #1993, +// #2609): an async memo answered, then re-asked in one flush. When the +// memo's input is read through `latest()` (or through a sync memo), the +// write's re-ask reaches the memo after its landing has run, and the landing +// released the identity memo's wait: it runs with no value and waits on +// nothing. It still derives from the answer — which of the two the flush ran +// first is the flush's order, not the reader's — so `latest` of it shows +// what `latest` of the memo shows. +const task = () => new Promise(r => setTimeout(r, 0)); + +async function run(shape: "latest" | "superseded" | "via-memo", view: "memo" | "identity") { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(shape === "via-memo" ? 2 : 1); + const flight = (v: number) => new Promise(resolve => asks.push({ v, resolve })); + let m: () => number; + if (shape === "via-memo") { + const m0 = createMemo(() => latest(s) + 1); + m = createMemo(() => flight(m0() + 1)); + } else m = createMemo(() => flight(latest(s) + 1)); + const x = view === "memo" ? m : createMemo(() => m()); + let leaf = ""; + let output = ""; + createRoot(() => { + const b = createLoadingBoundary( + () => { + createRenderEffect( + () => `l:${latest(x)}`, + v => { + leaf = v; + } + ); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + output = v; + }); + }); + const settle = (pick: number) => { + const a = asks.splice(pick % asks.length, 1)[0]; + a.resolve(a.v); + }; + flush(); + if (shape === "superseded") { + // A second question while the first is in flight; its answer lands in + // the tick a write asks a third. + setS(2); + await Promise.resolve(); + settle(1); + setS(1); + } else { + await task(); + flush(); + settle(0); + setS(0); + } + await task(); + flush(); + return output === "content" ? leaf : output; +} + +describe("an answered-then-reasked first answer reaches a reader its landing released", () => { + for (const [shape, shown] of [ + ["latest", "l:2"], + ["superseded", "l:3"], + ["via-memo", "l:4"] + ] as const) + it(`${shape}: an identity memo shows what the memo shows`, async () => { + expect(await run(shape, "memo")).toBe(shown); + expect(await run(shape, "identity")).toBe(shown); + }); +}); diff --git a/packages/web/test/async-gen/e2-released-reader.spec.ts b/packages/web/test/async-gen/e2-released-reader.spec.ts new file mode 100644 index 0000000000..f7402f4fd0 --- /dev/null +++ b/packages/web/test/async-gen/e2-released-reader.spec.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment jsdom + */ +// async-gen E2/E3 (3289 #310, 91501 #1993, 91501 #2609; D-22 amended +// 2026-10-30, F-9): an async memo answered, then re-asked in one flush, with +// its input read through `latest()`. The landing ran before the re-ask +// reached the memo, so it released the identity memo's (and the derived +// store's) wait, and the served-only-while-waiting rule left them on +// `[ROOT-L]` while the plain form showed the answer. +import { expect, test } from "vitest"; +import fc from "fast-check"; +import { runScenario } from "./run.js"; +import { scenarioArb } from "./scenario.js"; + +for (const [seed, i] of [ + [3289, 310], + [91501, 1993], + [91501, 2609] +] as [number, number][]) + test(`${seed} #${i}: an identity memo and a derived store show what the memo shows`, async () => { + const sc = fc.sample(scenarioArb(), { seed, numRuns: i + 1 })[i]; + const a = await runScenario(sc); + for (const variant of [{ identity: true }, { store: true }]) { + const b = await runScenario(sc, variant); + const at = (r: typeof a) => r.screens.filter((_, j) => a.settled[j] && b.settled[j]); + expect(at(b), JSON.stringify(variant)).toEqual(at(a)); + } + }); From 22a3157c8d6cae7cd6d8f693b621979d88153053 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:22:54 +0000 Subject: [PATCH 12/14] chore(size): re-measure the answered-then-reasked serve over next @ 230c670 Live server components 38.85 -> 38.96 KB (117,747; Size-Exception in the PR body), isPending/latest 11.17 -> 11.26 KB (31,002), compiled live 42.79 -> 42.85 KB (125,887); live + router re-records minified (143,150). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/floor-caps.json | 4 ++-- scripts/size/scenarios.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 393492092d..a2e797b6e5 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -16,8 +16,8 @@ "minified": 101638 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.85 KB", - "minified": 117514 + "cap": "38.96 KB", + "minified": 117747 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.29 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 99347622f1..229b00c9e0 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1444,9 +1444,10 @@ module.exports = [ // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 30,569 -> 30,596 B (+27 B), measured locally (linux) at 11,092 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 11.10 KB -> 11.12 KB, recorded minified 30,596 -> 30,641 B (+45 B), measured locally (linux) at 11,109 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 11.12 KB -> 11.17 KB, recorded minified 30,641 -> 30,771 B (+130 B), measured locally (linux) at 11,153 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "11.17 KB", + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ 230c670): 11.17 KB -> 11.26 KB, recorded minified 30,771 -> 31,002 B (+231 B), measured locally (linux) at 11,242 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "11.26 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 30771, + capMinified: 31002, alias }, { @@ -5615,6 +5616,7 @@ module.exports = [ // A verdict reader born into a hold reads it whichever flush runs it (#4426, D-25/D-27; re-measured over next @ 9c4e4a8): 38.81 KB -> 38.83 KB, recorded minified 117,251 -> 117,331 B, measured locally (linux) at 38,817 B brotli. `stagedScreen`'s held-node check (+20 B minified in core.js) on top of next's +13 B over this floor's record (#4452); the brotli is layout. Correctness; a frozen floor, Size-Exception in the PR body. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 38.83 KB -> 38.85 KB, recorded minified 117,331 -> 117,376 B (+45 B), measured locally (linux) at 38,839 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): cap unchanged at 38.85 KB, recorded minified 117,376 -> 117,514 B (+138 B), measured locally (linux) at 38,849 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ 230c670): 38.85 KB -> 38.96 KB, recorded minified 117,514 -> 117,747 B (+233 B), measured locally (linux) at 38,947 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5933,11 +5935,12 @@ module.exports = [ // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): 42.71 KB -> 42.73 KB, recorded minified 125,478 -> 125,518 B (+40 B), measured locally (linux) at 42,715 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // #4428 fallback memo lazy (+7 B over next @ ab3d2add, which sits +15 over this record; 2026-10-30): 42.73 KB -> 42.75 KB, recorded minified 125,518 -> 125,540 B (+22 B), measured locally (linux) at 42,735 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 42.75 KB -> 42.79 KB, recorded minified 125,540 -> 125,655 B (+115 B), measured locally (linux) at 42,777 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.79 KB", + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ 230c670): 42.79 KB -> 42.85 KB, recorded minified 125,655 -> 125,887 B (+232 B), measured locally (linux) at 42,837 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.85 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125655, + capMinified: 125887, alias: pageAlias }, { @@ -6340,11 +6343,12 @@ module.exports = [ // Batch re-record (2026-10-30, over next @ 563b6b0): cap unchanged, recorded minified 142,706 -> 142,735 B (+29 B), measured locally (linux) at 48,365 B brotli. Growth is the 2026-10-30 merges (#4454, #4457, #4461). Recorded minified = measured. // D-28's verdict-seat reset compares holds, not lane objects: a seated leaf reading a merged transaction's verdict lane stays the hold's verdict reader (`laneRead`) (#4427, #4455, 2026-10-30, over next @ cddaa76): cap unchanged at 48.44 KB, recorded minified 142,735 -> 142,780 B (+45 B), measured locally (linux) at 48,394 B brotli. Correctness. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 48.44 KB -> 48.51 KB, recorded minified 142,780 -> 142,918 B (+138 B), measured locally (linux) at 48,498 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ 230c670): cap unchanged at 48.51 KB, recorded minified 142,918 -> 143,150 B (+232 B), measured locally (linux) at 48,496 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: "48.51 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,509 B (+58 B minified over next's record), measured locally (linux) at 47,516 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 140,463 -> 140,485 B (+22 B minified over next's record), measured locally (linux) at 47,547 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 140,485 -> 140,983 B (+405 B minified over next), measured locally (linux) at 47,722 B brotli (+162 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 142918, + capMinified: 143150, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From d3b851518b143d31023400ef548f04157baf5142 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:29:35 +0000 Subject: [PATCH 13/14] chore(size): re-measure the answered-then-reasked serve over next @ d0fc285 Takes next's ledger (recorded after #4471/#4479) and re-records only the scenarios this PR moves: isPending/latest 11.19 -> 11.28 KB (31,053), live server components 38.91 -> 38.96 KB (117,793; Size-Exception in the PR body), compiled live 42.83 -> 42.87 KB (125,938), live + router 48.51 -> 48.58 KB (143,201). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/floor-caps.json | 4 ++-- scripts/size/scenarios.js | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 0c4f9878e8..411d5bc8ae 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -16,8 +16,8 @@ "minified": 101638 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.91 KB", - "minified": 117566 + "cap": "38.96 KB", + "minified": 117793 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.29 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 7bcbb47f12..5d91b2ecf9 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1446,9 +1446,10 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 11.12 KB -> 11.17 KB, recorded minified 30,641 -> 30,771 B (+130 B), measured locally (linux) at 11,153 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): cap unchanged at 11.17 KB, recorded minified 30,771 -> 30,800 B (+29 B), measured locally (linux) at 11,166 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): 11.17 KB -> 11.19 KB, recorded minified 30,800 -> 30,822 B (+22 B), measured locally (linux) at 11,177 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "11.19 KB", + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ d0fc285): 11.19 KB -> 11.28 KB, recorded minified 30,822 -> 31,053 B (+231 B), measured locally (linux) at 11,267 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "11.28 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 30822, + capMinified: 31053, alias }, { @@ -5619,6 +5620,7 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): cap unchanged at 38.85 KB, recorded minified 117,376 -> 117,514 B (+138 B), measured locally (linux) at 38,849 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): 38.85 KB -> 38.91 KB, recorded minified 117,514 -> 117,544 B (+30 B), measured locally (linux) at 38,898 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): cap unchanged at 38.91 KB, recorded minified 117,544 -> 117,566 B (+22 B), measured locally (linux) at 38,849 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ d0fc285): 38.91 KB -> 38.96 KB, recorded minified 117,566 -> 117,793 B (+227 B), measured locally (linux) at 38,950 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5939,11 +5941,12 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 42.75 KB -> 42.79 KB, recorded minified 125,540 -> 125,655 B (+115 B), measured locally (linux) at 42,777 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): 42.79 KB -> 42.83 KB, recorded minified 125,655 -> 125,684 B (+29 B), measured locally (linux) at 42,817 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): cap unchanged at 42.83 KB, recorded minified 125,684 -> 125,706 B (+22 B), measured locally (linux) at 42,822 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.83 KB", + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ d0fc285): 42.83 KB -> 42.87 KB, recorded minified 125,706 -> 125,938 B (+232 B), measured locally (linux) at 42,857 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.87 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125706, + capMinified: 125938, alias: pageAlias }, { @@ -6348,11 +6351,12 @@ module.exports = [ // A staged error reaches the memos and derived stores that derive from it (#4456, 2026-10-30, re-measured over next @ b773146): 48.44 KB -> 48.51 KB, recorded minified 142,780 -> 142,918 B (+138 B), measured locally (linux) at 48,498 B brotli. The settle walk marks a derivation of a staged error with it, and a derived store's family hears it (D-41), with the verdict engine alone. Correctness; Size-Exception in #4456. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): cap unchanged at 48.51 KB, recorded minified 142,918 -> 142,947 B (+29 B), measured locally (linux) at 48,462 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): cap unchanged at 48.51 KB, recorded minified 142,947 -> 142,969 B (+22 B), measured locally (linux) at 48,496 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "48.51 KB", + // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ d0fc285): 48.51 KB -> 48.58 KB, recorded minified 142,969 -> 143,201 B (+232 B), measured locally (linux) at 48,566 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "48.58 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,509 B (+58 B minified over next's record), measured locally (linux) at 47,516 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 140,463 -> 140,485 B (+22 B minified over next's record), measured locally (linux) at 47,547 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 140,485 -> 140,983 B (+405 B minified over next), measured locally (linux) at 47,722 B brotli (+162 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 142969, + capMinified: 143201, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From 2417bf1303faf6f17e31d7558269eeafa9a23ad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:03:06 +0000 Subject: [PATCH 14/14] fix(signals): every answer resolved in the tick a write re-asks it rides the write's flush (#4495) Two async memos m1, m2 answer, then a write re-asks both, in one tick. m1's landing flushed synchronously (`asyncWrite`), and that flush carried the queued write: it re-asked m2 before m2's answer's reaction ran, so the answer arrived superseded (`_inFlight !== result`) and was dropped. A memo over both then had no value and `latest` of it threw (D-22 says 13). A landing that arrives while a write's flush is queued (a staged value in `pendingNodes`, captured before the landing stages its own) no longer flushes: the write's flush carries it with every answer whose reaction is queued ahead of it (F-10), each answered, then re-asked (D-22, F-9). Only `latest` and `isPending` tell the difference, so it is gated on the verdict engine (`holdWorkHook`); a stream's next pull still waits on its landing's flush. Size: isPending/latest 11.28 -> 11.30 KB (31,087 B), compiled live server components 42.87 -> 42.90 KB (125,978 B); hello world unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .../two-sources-answered-reasked-4495.md | 5 + packages/signals/src/core/async.ts | 14 +- .../two-sources-answered-reasked-4495.test.ts | 138 ++++++++++++++++++ scripts/size/scenarios.js | 10 +- 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 .changeset/two-sources-answered-reasked-4495.md create mode 100644 packages/signals/tests/two-sources-answered-reasked-4495.test.ts diff --git a/.changeset/two-sources-answered-reasked-4495.md b/.changeset/two-sources-answered-reasked-4495.md new file mode 100644 index 0000000000..805cbfd8f9 --- /dev/null +++ b/.changeset/two-sources-answered-reasked-4495.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Two or more async memos whose answers resolve in the same tick as a plain write re-asking them are all answered, then re-asked (D-22, #4495): a memo over them reads every answer through `latest` and is pending on the re-ask. An earlier landing's flush no longer re-asks the others before their answers arrive, dropping them. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index cdb4918fdc..a7f5d2bb05 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -41,6 +41,7 @@ import { joinFuture, MAINLINE_QUESTION, passLane, + pendingNodes, question, queuePendingNode, schedule, @@ -590,6 +591,9 @@ export function handleAsync( // landing is the verdict engine's (`holdWorkHook`). if (el._flags & REACTIVE_DIRTY && !(holdWorkHook && el._statusFlags & STATUS_UNINITIALIZED)) return; + // A write whose flush is queued (before this landing stages its own): see + // the flush below (#4495). Not status bookkeeping queued with no value. + const carried = holdWorkHook && pendingNodes.some(n => n._pendingValue !== NOT_PENDING); const wasUninitialized = el._statusFlags & STATUS_UNINITIALIZED; landStatus(el); // Attribution hook: lets the engine snapshot state before the landing @@ -650,7 +654,15 @@ export function handleAsync( } settlePendingSource(el); schedule(); - flush(); + // An answer that lands while a write's flush is queued resolved before + // that flush was queued, with every answer whose reaction is queued ahead + // of it: the write's flush carries them all (F-10), each answered, then + // re-asked (D-22). Flushed here, it would re-ask the write's other async + // before their answers' reactions ran, and drop them (#4495). Only + // `latest`/`isPending` tell an answered-then-reasked node from one with + // no value, so it is the verdict engine's (`holdWorkHook`). A stream's + // next pull waits for its landing's flush. + if (then || !carried) flush(); then?.(); }; diff --git a/packages/signals/tests/two-sources-answered-reasked-4495.test.ts b/packages/signals/tests/two-sources-answered-reasked-4495.test.ts new file mode 100644 index 0000000000..e31eb1a406 --- /dev/null +++ b/packages/signals/tests/two-sources-answered-reasked-4495.test.ts @@ -0,0 +1,138 @@ +/** + * #4495 (D-22, F-9, F-10): two async memos m1, m2 both answer, then a write re-asks them, + * all in one tick. The memo c = m1() + m2() reads both answers through `latest` — 2 + 11 = 13 + * — and is pending on the re-ask: both answers resolved before the write, so both are produced + * values (D-22: answered, then re-asked), whichever reaction runs first. + * + * Cause: an async landing flushed synchronously (`asyncWrite`), and that flush carried the + * queued write: it re-asked m2 before m2's answer's reaction ran, so the answer arrived + * superseded and was dropped. A landing that arrives while a write's flush is queued is now + * carried by that flush (F-10), with every answer whose reaction is queued ahead of it. + * + * From the review repro 4459c-two-sources-landing-order (re-review of #4459). + */ +import { expect, it } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +const task = () => new Promise(r => setTimeout(r, 0)); +const attempt = (f: () => unknown) => { + try { + return f(); + } catch (e: any) { + return "throws " + e?.constructor?.name; + } +}; + +type Shape = "one" | "two" | "two-reversed" | "m1-unasked"; + +function setup(shape: Shape) { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const mk = (off: number, reask = true) => + createMemo(() => { + const v = off + (reask ? s() : 1); + return new Promise(resolve => asks.push({ v, resolve })); + }); + const m1 = mk(1, shape !== "m1-unasked"), + m2 = mk(10); + const c = createMemo(() => + shape === "one" ? m1() : shape === "two-reversed" ? m2() + m1() : m1() + m2() + ); + const seen: string[] = []; + createRoot(() => { + createRenderEffect( + () => `${attempt(() => latest(c))}|${isPending(c)}`, + v => void seen.push(v) + ); + }); + return { asks, setS, c, seen }; +} + +/** Both answer, then the write re-asks, in one tick. */ +async function answerThenReask(shape: Shape, order: "asked" | "reversed" = "asked") { + const t = setup(shape); + flush(); + await task(); + flush(); + const answers = t.asks.splice(0); + if (order === "reversed") answers.reverse(); + for (const a of answers) a.resolve(a.v); + t.setS(2); + await task(); + flush(); + return t; +} + +it("one source: answered then re-asked in one tick, latest(c) reads the answer (D-22)", async () => { + const { c } = await answerThenReask("one"); + expect(attempt(() => latest(c))).toBe(2); +}); + +it("two sources: both answered then re-asked in one tick, latest(c) reads both answers (D-22)", async () => { + const { c, seen } = await answerThenReask("two"); + expect(attempt(() => latest(c))).toBe(13); + // F-9: the re-ask is a new question; c is pending on it. + expect(isPending(c)).toBe(true); + expect(seen.at(-1)).toBe("13|true"); +}); + +it("two sources, answers resolved in the other order: the same (D-22)", async () => { + const { c } = await answerThenReask("two", "reversed"); + expect(attempt(() => latest(c))).toBe(13); +}); + +it("two sources, c reads m2 first: the same (D-22)", async () => { + const { c } = await answerThenReask("two-reversed"); + expect(attempt(() => latest(c))).toBe(13); +}); + +it("only the later answer's source is re-asked: its answer is not dropped (D-22)", async () => { + // m1 = 1 + 1 answers and is not re-asked; m2 = 10 + s is. + for (const order of ["asked", "reversed"] as const) { + const { c } = await answerThenReask("m1-unasked", order); + expect(attempt(() => latest(c))).toBe(13); + } +}); + +it("the re-ask's answers land as the next value", async () => { + const { asks, c } = await answerThenReask("two"); + for (const a of asks.splice(0)) a.resolve(a.v); + await task(); + flush(); + expect(attempt(() => latest(c))).toBe(3 + 12); + expect(isPending(c)).toBe(false); +}); + +it("control: two sources answered in their own tick, then re-asked, read 13", async () => { + const { asks, setS, c } = setup("two"); + flush(); + await task(); + flush(); + for (const a of asks.splice(0)) a.resolve(a.v); + await task(); + flush(); + setS(2); + flush(); + expect(attempt(() => latest(c))).toBe(13); +}); + +it("an answer resolved after the write is to the superseded question (control)", async () => { + const { asks, setS, c } = setup("two"); + flush(); + await task(); + flush(); + const answers = asks.splice(0); + setS(2); + for (const a of answers) a.resolve(a.v); + await task(); + flush(); + expect(attempt(() => latest(c))).toBe("throws NotReadyError"); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 5d91b2ecf9..c4e198423b 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1447,9 +1447,10 @@ module.exports = [ // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): cap unchanged at 11.17 KB, recorded minified 30,771 -> 30,800 B (+29 B), measured locally (linux) at 11,166 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): 11.17 KB -> 11.19 KB, recorded minified 30,800 -> 30,822 B (+22 B), measured locally (linux) at 11,177 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ d0fc285): 11.19 KB -> 11.28 KB, recorded minified 30,822 -> 31,053 B (+231 B), measured locally (linux) at 11,267 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "11.28 KB", + // Every answer that resolved in the tick a write re-asks it rides the write's flush (#4495, 2026-10-30, over #4459 @ d3b8515): 11.28 KB -> 11.30 KB, recorded minified 31,053 -> 31,087 B (+34 B), measured locally (linux) at 11,286 B brotli. A landing that arrives while a write's flush is queued does not flush, with the verdict engine alone. Correctness (D-22, F-10); Size-Exception in the PR for #4495. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "11.30 KB", // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 28,693 -> 29,086 B (+343 B minified over next), measured locally (linux) at 10,565 B brotli (+135 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 31053, + capMinified: 31087, alias }, { @@ -5942,11 +5943,12 @@ module.exports = [ // A verdict reader born into a hold joins it at its re-pass (#4471, #4467, 2026-10-30, re-measured over next @ 749b0d7): 42.79 KB -> 42.83 KB, recorded minified 125,655 -> 125,684 B (+29 B), measured locally (linux) at 42,817 B brotli. Correctness (D-25, D-13); Size-Exception in #4471 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // A derived store with no live leaf skips its flight's observe (#4479, 2026-10-30, re-measured over next @ 663ecb0): cap unchanged at 42.83 KB, recorded minified 125,684 -> 125,706 B (+22 B), measured locally (linux) at 42,822 B brotli. Correctness (F-3, D-11); Size-Exception in #4479 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // An answered-then-reasked first answer lands and is served once a flush, out of line, including to a reader its landing released (#4459, 2026-10-30, re-measured over next @ d0fc285): 42.83 KB -> 42.87 KB, recorded minified 125,706 -> 125,938 B (+232 B), measured locally (linux) at 42,857 B brotli. Correctness (D-22 as amended 2026-10-30, F-9); Size-Exception in #4459 for the floor. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.87 KB", + // Every answer that resolved in the tick a write re-asks it rides the write's flush (#4495, 2026-10-30, over #4459 @ d3b8515): 42.87 KB -> 42.90 KB, recorded minified 125,938 -> 125,978 B (+40 B), measured locally (linux) at 42,889 B brotli. A landing that arrives while a write's flush is queued does not flush, with the verdict engine alone. Correctness (D-22, F-10); Size-Exception in the PR for #4495. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.90 KB", // An Errored heals when the last reader that caught its error is disposed (#4373, #4384, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,249 B (+58 B minified over next's record), measured locally (linux) at 41,899 B brotli. Recorded minified = measured; CI re-bases. // Boundary readers keyed by the source they wait on (#4373), with enterLane's tail folded into one else-chain as an offset (-29 B on lane pages) (#4384, 2026-10-28, re-recorded over next @ 1b32ff2e): recorded minified 123,203 -> 123,224 B (+21 B minified over next's record), measured locally (linux) at 41,966 B brotli. Recorded minified = measured; CI re-bases. // latest() read inline and through a memo show the same screen (#4284 through a memo; a plain write beside an async memo), 2026-10-29, over next @ 7ebd4a32: recorded minified 123,224 -> 123,733 B (+403 B minified over next), measured locally (linux) at 42,119 B brotli (+169 B over next). A render effect a verdict memo's change re-runs reads as its verdict reader for that pass (`laneDirty`), a pass that read a staging before the frame had a transaction re-derives once as a verdict reader only if the frame parks, `latest()` of a first load in flight in a verdict lane is loading (also through an identity memo, a derived store and `isPending`), a lane's dissolution leaves such a load and the nodes pending on it uninitialized instead of committing `undefined`, a verdict lane born at a seam is judged at it, a pass born in a staged reader stays the frame's on a verdict-memo read (C4, #4010), a plain write beside `latest()` computes once (the seam re-derives a pre-transaction staged reader only if the frame parks), a verdict reader's plain read of a staging in a transaction only a `latest()` opened is the frame's read, so a memo of `latest()` computes once too (D-22; not a pass with no value yet, D-6), and an error stays the display while the memo it came through re-asks, a Loading fallback behind it included (D-39, async-gen 91501 #1831). recompute's CONFIG_VERDICT clear now sits behind the lane engine: -21 B minified on every floor. Correctness; the maintainer: "only flag me if brotli goes more than 20kb" (2026-10-29). Cap unchanged; recorded minified = measured. - capMinified: 125938, + capMinified: 125978, alias: pageAlias }, {