From 1d6b8b7681b78096d47630fed7b2309b9eb50ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:04:25 +0000 Subject: [PATCH 01/11] fix(signals): an async memo over a verdict memo asks each question once A memo that reads a verdict memo is a verdict reader (F-6, #4050), and propagateStatus re-derived every verdict reader when its source went pending. Re-derived, an async one was served the committed value it had already derived from (A10) and asked that question again; that flight held the verdict lane, and the source's held landing re-ran it as lane work onto the same committed value, a third ask. Only the landing's re-derive asked the new question. Now an async memo that is a verdict reader only by reading a verdict memo (it opened no isPending/latest window of its own: REACTIVE_WINDOW, set per pass by markVerdictReader) drops CONFIG_VERDICT when that memo goes pending and takes the plain pending mark: it is pending through the flight (F-9) and the hold's work until it lands, reading plainly (D-25, #3884), and asks once at the landing. Sync readers re-derive as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/verdict-memo-flight-no-reask.md | 5 + packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/async.ts | 15 +- packages/signals/src/core/constants.ts | 6 + packages/signals/src/core/core.ts | 7 +- packages/signals/src/core/verdict.ts | 3 +- .../verdict-memo-flight-no-reask.test.ts | 131 ++++++++++++++++++ scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 10 +- 9 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 .changeset/verdict-memo-flight-no-reask.md create mode 100644 packages/signals/tests/verdict-memo-flight-no-reask.test.ts diff --git a/.changeset/verdict-memo-flight-no-reask.md b/.changeset/verdict-memo-flight-no-reask.md new file mode 100644 index 0000000000..eec97bb072 --- /dev/null +++ b/.changeset/verdict-memo-flight-no-reask.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question again when that memo re-asks. Reading such a memo makes the reader a verdict reader too (F-6), and a verdict reader is re-derived when its source goes pending. The re-derive was served the committed value that the memo had already derived from, so it repeated the request. That extra flight then held the verdict lane, and the landing ran it a third time on the same input. Now an async reader that opened no `isPending()` or `latest()` window of its own goes pending with the flight, like any other derivation, and asks once when the flight lands. Sync readers re-derive as before. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 84c5fc82e7..c2b1909734 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -54,7 +54,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | async.ts×1 core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | | A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 38214e6566..2fd5ba66f3 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -990,8 +990,21 @@ export function propagateStatus(el: Computed, status: number, error: any): // Inheriting the error would make a probe's reader errored. A boundary's // output (CONFIG_REDERIVE, boundaries.ts) the same: content or fallback // is its pass's call. + // Not an async memo that is a verdict reader only by reading a verdict + // memo (F-6, #4050: it opened no window of its own — REACTIVE_WINDOW, + // whose bit >> 6 is CONFIG_VERDICT's) when that verdict memo goes + // pending: re-derived, it is served the committed value it already + // derived from (A10) and asks that question again. What it read ahead is + // in flight, so it is pending through the flight like any derivation + // (F-9) and the hold's work until the flight lands, reading plainly + // (D-25, #3884): it asks once, at the landing. A sync one re-derives as + // before — asking nothing, it stays the lane's. (`_inFlight` is its last + // pass's flight, landed or not; a sync pass clears it.) if ( - sub._config & (CONFIG_REDERIVE | CONFIG_VERDICT) || + (sub._config &= ~(pendingSource && sub._x?._inFlight + ? el._config & (~sub._flags >> 6) & CONFIG_VERDICT + : 0)) & + (CONFIG_REDERIVE | CONFIG_VERDICT) || (status === STATUS_PENDING && link._gen !== sub._depGen) ) { enqueueSub(sub); diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 6645710f52..eddd98dd83 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -68,6 +68,12 @@ export const REACTIVE_SCREEN_READ = 1 << 18; * later read of the same pass routed it into a verdict lane — that was not * the probe's answer (lanes.ts). Per pass. */ export const REACTIVE_PROBE_UNANSWERED = 1 << 19; +/** The pass opened a verdict window of its own (`isPending`/`latest`) — not + * only read a verdict memo (F-6, core.ts `read`): a verdict memo it reads + * going pending changes its answer (async.ts `propagateStatus`). Per pass, + * kept until the next. CONFIG_VERDICT << 6, which the propagation relies on + * (1 << 20 is lanes.ts's LEFT_SHOWN). */ +export const REACTIVE_WINDOW = 1 << 21; // Static configuration bits packed into Owner/Computed/Signal _config. export const CONFIG_OWNED_WRITE = 1 << 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 1bf2051f8e..01c16beaac 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -50,6 +50,7 @@ import { REACTIVE_LANE_DIRTY, REACTIVE_LANE_READ, REACTIVE_PROBE_UNANSWERED, + REACTIVE_WINDOW, REACTIVE_SCREEN_READ, REACTIVE_IN_HEAP_HEIGHT, REACTIVE_LAZY, @@ -628,7 +629,8 @@ export function recompute(el: Computed, create: boolean = false): void { REACTIVE_STAGED_READ | REACTIVE_LANE_READ | REACTIVE_SCREEN_READ | - REACTIVE_PROBE_UNANSWERED)) | + REACTIVE_PROBE_UNANSWERED | + REACTIVE_WINDOW)) | (create ? el._flags & REACTIVE_SNAPSHOT_STALE : 0); context = oldcontext; if (released) for (const node of released) releaseIfSettledUnobserved(node); @@ -975,7 +977,8 @@ function updateIfNecessary(el: Computed): void { REACTIVE_STAGED_READ | REACTIVE_LANE_READ | REACTIVE_SCREEN_READ | - REACTIVE_PROBE_UNANSWERED); + REACTIVE_PROBE_UNANSWERED | + REACTIVE_WINDOW); } export function computed(fn: (prev?: T) => T | PromiseLike | AsyncIterable): Computed; diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 8cc2283301..86997ed7a5 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -27,6 +27,7 @@ import { REACTIVE_PROBE_UNANSWERED, REACTIVE_RECOMPUTING_DEPS, REACTIVE_STAGED_READ, + REACTIVE_WINDOW, STATUS_ERROR, STATUS_PENDING, STATUS_UNINITIALIZED @@ -252,7 +253,7 @@ function markVerdictReader(window: number): void { while ((p = creatorPass(p!._parent))?._flags! & REACTIVE_RECOMPUTING_DEPS) born |= p!._flags & (REACTIVE_JOINED | REACTIVE_STAGED_READ); if (!born) c._config |= CONFIG_VERDICT; - c._flags |= REACTIVE_PROBED | born; + c._flags |= REACTIVE_PROBED | REACTIVE_WINDOW | born; // Observe tier: which windows this pass entered (1 `isPending`, 2 // `latest`) — the hold census names the affordance (attribution.ts). if (__OBSERVE__) (c as any)._devWindows = ((c as any)._devWindows ?? 0) | window; diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts new file mode 100644 index 0000000000..bc4bc6b5be --- /dev/null +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -0,0 +1,131 @@ +// A memo that reads a verdict memo is a verdict reader (F-6, #4050) without +// opening a window of its own. When that verdict memo goes pending, what the +// memo read ahead is in flight: it is pending through the flight like any +// derivation (F-9) and the hold's work until it lands (D-25, #3884), so an +// async one asks its next question once, at the landing. Before: `m1` over +// `m0 = ask(1 + latest(s) + s())` was re-derived when `m0` re-asked, served +// `m0`'s committed value (A10) — the one it had already derived `m1:5` from — +// and asked `m1:5` again; that flight held the verdict lane, `m0`'s landing +// re-ran it as lane work onto the same committed value (a third `m1:5`), and +// only the landing's re-derive asked `m1:6`. +import { expect, it } from "vitest"; +import { + createErrorBoundary, + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +async function settle() { + for (let i = 0; i < 4; i++) { + await sleep(15); + flush(); + } +} + +const expected = { + // The verdict lane answers m0's display-ahead intermediate (`latest(s)` = 2 + // beside the screen's `s()` = 1) and m1 over it while `h` holds the write + // (F-6: async derived from a display-ahead read is the lane's), then the + // landing asks each memo once. + "latest held": ["m0:4", "m1:5", "m0:5", "m1:6"], + "latest unheld": ["m0:4", "m0:5", "m1:6"], + "plain held": ["m0:5", "m1:6"], + "plain unheld": ["m0:5", "m1:6"] +}; + +for (const useLatest of [true, false]) + for (const heldWrite of [true, false]) { + const name = `${useLatest ? "latest" : "plain"} ${heldWrite ? "held" : "unheld"}`; + it(`${name}: an async memo over a verdict memo asks each question once`, async () => { + const [s, setS] = createSignal(1); + const asks: string[] = []; + const ask = (name: string, v: number) => { + asks.push(`${name}:${v}`); + return sleep(10).then(() => { + if (v === 3) throw new Error(`${name}:${v}`); + return v; + }); + }; + const out: string[] = []; + createRoot(() => { + const m0 = createMemo(() => ask("m0", 1 + (useLatest ? latest(s) : s()) + s())); + const m1 = createMemo(() => ask("m1", 1 + m0())); + // Holds the write's frame for 80ms (the boundary's content reads it). + const h = createMemo(() => (s() === 1 ? 1 : sleep(80).then(() => 1))); + createErrorBoundary( + () => + createLoadingBoundary( + () => { + createRenderEffect( + () => (heldWrite ? h() : 0, String(isPending(m1))), + v => void out.push(v) + ); + return null; + }, + () => "L" + ), + () => "E" + ); + }); + flush(); + await settle(); + expect(asks).toEqual(["m0:3"]); + setS(2); + await settle(); + await settle(); + const after = asks.slice(1); + expect(after).toEqual(expected[name as keyof typeof expected]); + const m1Asks = after.filter(a => a.startsWith("m1")); + expect(new Set(m1Asks).size).toBe(m1Asks.length); + expect(out[out.length - 1]).toBe("false"); + }); + } + +// Settled memos, no boundary hold: the verdict lane shows `m0:4`/`m1:5`, the +// write's landing re-asks `m0`, and `m1` waits on it — pending throughout, +// its next question asked once. +for (const reader of ["m1()", "[isPending(m1), m1()]"] as const) + it(`${reader}: a memo over a re-asking verdict memo waits on it, pending`, async () => { + const [s, setS] = createSignal(1); + const asks: string[] = []; + const ask = (name: string, v: number) => { + asks.push(`${name}:${v}`); + return sleep(10).then(() => v); + }; + const out: string[] = []; + createRoot(() => { + const m0 = createMemo(() => ask("m0", 1 + latest(s) + s())); + const m1 = createMemo(() => ask("m1", 1 + m0())); + createLoadingBoundary( + () => { + createRenderEffect( + () => (reader === "m1()" ? String(m1()) : `${isPending(m1)},${m1()}`), + v => void out.push(v) + ); + return null; + }, + () => "L" + ); + }); + flush(); + await settle(); + expect(asks).toEqual(["m0:3", "m1:4"]); + setS(2); + await settle(); + await settle(); + expect(asks.slice(2)).toEqual(["m0:4", "m1:5", "m0:5", "m1:6"]); + if (reader === "m1()") expect(out).toEqual(["4", "5", "6"]); + else { + expect(out[0]).toBe("false,4"); + expect(out[out.length - 1]).toBe("false,6"); + // Never final before `m1:6` lands. + expect(out.slice(1, -1).every(v => v.startsWith("true,"))).toBe(true); + } + }); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index a5df77624f..11f9bd4ce9 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": "37.75 KB", - "minified": 115217 + "minified": 115239 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 8fb2034567..061b682e9d 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2675,10 +2675,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 96,978 B unchanged, measured locally (linux) at 31,763 B brotli / 96,976 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ 178605cf): recorded minified 97,009 -> 97,024 B (+15 B minified over next's record), measured locally (linux) at 31,774 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, over next @ 6b792a95, after #4355; re-recorded over next @ b68942ca): cap 31.80 KB unchanged, recorded minified 96,978 -> 97,023 B (+45 B minified over next's record, measured locally on linux; every boundary-carrying scenario pays the same +38–52 B, inside its recorded minified except this one, which sat at its cap). A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,084 B (+39 B minified over next), measured locally (linux) at 31,823 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "31.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 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 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 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. - capMinified: 97045, + capMinified: 97084, alias }, { @@ -5474,6 +5475,7 @@ module.exports = [ // Off-screen fallback disposed in the pass that picks content (#4282 / #4268, 2026-10-26, over next @ fbd375d1): 37.72 KB -> 37.75 KB, recorded minified 115,193 -> 115,217 B (+24 B; +24 B over next), (floor-caps.json) measured in CI at 37,737 B brotli. A re-armed Loading disposes a fallback its committed output does not show in that pass, so the fallback's pending render effects stop holding the frame. Size-Exception accepted by the maintainer (2026-10-26). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. // A derived store's first landing is loading, not pending (async-gen campaign 1, E3 store twin, 2026-10-28, over next @ 6b792a95, after the landing wake): cap 37.75 KB unchanged (brotli already over it on next at 37,978 B; judged on recorded minified), recorded minified 115,217 B unchanged (frozen floor, floor-caps.json untouched), measured locally (linux) at 37,958 B brotli / 115,150 B minified (+17 B over the wake, +17 B over next). A leaf born staged at a never-committed derive's landing answered `isPending` true for one pass; `nodeValue` now links such a leaf and serves its staging with no verdict, as for a memo born staged (F-9, D-30), and the get trap's absent-key path no longer reads the node before `nodeValue` does. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 115,217 B unchanged, measured locally (linux) at 37,931 B brotli / 115,134 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,239 B in floor-caps.json (+22 B minified over next, 2 B past the 20 B allowance), measured locally (linux) at 38,112 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5775,10 +5777,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 123,182 B unchanged, measured locally (linux) at 41,948 B brotli / 123,182 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,203 B (+12 B minified over next's record), measured locally (linux) at 41,872 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 41.56 KB unchanged, recorded minified 123,182 -> 123,228 B (+46 B minified over next's record), measured locally (linux) at 41,856 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,285 B (+61 B minified over next), measured locally (linux) at 41,889 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "41.56 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. - capMinified: 123224, + capMinified: 123285, alias: pageAlias }, { @@ -6164,10 +6167,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 140,442 B unchanged, measured locally (linux) at 47,474 B brotli / 140,439 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,463 B (+12 B minified over next's record), measured locally (linux) at 47,529 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 47.16 KB unchanged, recorded minified 140,442 -> 140,485 B (+43 B minified over next's record), measured locally (linux) at 47,463 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 47.16 KB unchanged, recorded minified 140,485 -> 140,556 B (+71 B minified over next), measured locally (linux) at 47,552 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "47.16 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. - capMinified: 140485, + capMinified: 140556, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From 97894981b0646f82e6e3b40c1fb00d0d410034f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:29:17 +0000 Subject: [PATCH 02/11] fix(signals): an async memo with no verdict window of its own takes no verdict role #4397 review: with two verdict sources (`m1 = ask(1 + m0() + m0b())` over `m0 = ask(1 + latest(s) + s())` and `m0b = ask(10 * (latest(t) + t()))`), the grouped `[isPending(m1), m1()]` reader asked `m1:5` a second time on stale inputs after both sources landed. m1 lost CONFIG_VERDICT when its first source went pending, but a pass that threw NotReady at that source's read picked it back up (F-6, `read`). The settle walk then re-ran m1 as a verdict reader, and the committed values a hold had moved past were served to it (A10). The demotion is now per pass, in `recompute`. An async memo (new CONFIG_ASYNC: its last answering pass registered a flight) whose pass opened no isPending/latest window of its own (REACTIVE_WINDOW) does not become a verdict reader by reading a verdict memo. It waits on its sources like any derivation (F-9), reads them plainly (D-25), and asks each question once. The side-effecting clear in `propagateStatus` is gone, since that path no longer sees such a memo as a verdict reader. Pinned: two verdict memos into one async memo, grouped and split, with no question asked twice and equal final screens. A `test.fails` pin records that the grouped shape still skips the t lane's m1:15/m1:16 asks that split makes: whether a later write merges into an open transaction or opens its own lane depends on grouping (#4398). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/verdict-memo-flight-no-reask.md | 2 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/async.ts | 18 +---- packages/signals/src/core/constants.ts | 13 ++- packages/signals/src/core/core.ts | 24 ++++-- .../verdict-memo-flight-no-reask.test.ts | 79 +++++++++++++++++++ 6 files changed, 112 insertions(+), 26 deletions(-) diff --git a/.changeset/verdict-memo-flight-no-reask.md b/.changeset/verdict-memo-flight-no-reask.md index eec97bb072..45e2b6cd9c 100644 --- a/.changeset/verdict-memo-flight-no-reask.md +++ b/.changeset/verdict-memo-flight-no-reask.md @@ -2,4 +2,4 @@ "@solidjs/signals": patch --- -An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question again when that memo re-asks. Reading such a memo makes the reader a verdict reader too (F-6), and a verdict reader is re-derived when its source goes pending. The re-derive was served the committed value that the memo had already derived from, so it repeated the request. That extra flight then held the verdict lane, and the landing ran it a third time on the same input. Now an async reader that opened no `isPending()` or `latest()` window of its own goes pending with the flight, like any other derivation, and asks once when the flight lands. Sync readers re-derive as before. +An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question twice. Reading such a memo made the async memo a verdict reader too (F-6). So it was re-derived each time one of its verdict sources went pending, and it was served committed values that a hold had already moved past. Each time, it asked a question it had already answered. With two verdict sources, it could ask a stale question after both had landed. An async memo that opens no `isPending()` or `latest()` window of its own now takes no verdict role. It waits on its sources like any other derivation and asks each question once. Sync memos are unchanged. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index c2b1909734..c663142b6a 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -54,7 +54,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | async.ts×1 core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×4 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | | A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 2fd5ba66f3..643b71c9ac 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -1,5 +1,6 @@ import { CONFIG_AUTO_DISPOSE, + CONFIG_ASYNC, CONFIG_REDERIVE, CONFIG_GUESS, CONFIG_OVERRIDE, @@ -426,6 +427,7 @@ export function handleAsync( } if (!thenable && !iterator) { + el._config &= ~CONFIG_ASYNC; if (el._x !== null) el._x._inFlight = null; // A sync landing is the first real answer for a loadingValue node. el._loading = false; @@ -461,6 +463,7 @@ export function handleAsync( // fired _flightTeardown. A future non-recompute registration path must // release it here before overwriting _inFlight. ext(el)._inFlight = result as PromiseLike | AsyncIterable; + el._config |= CONFIG_ASYNC; // The question this flight answers (A18 provenance, lanes.ts): the action // whose slice asked it; mainline is always the current question. el._x!._q = question || MAINLINE_QUESTION; @@ -990,21 +993,8 @@ export function propagateStatus(el: Computed, status: number, error: any): // Inheriting the error would make a probe's reader errored. A boundary's // output (CONFIG_REDERIVE, boundaries.ts) the same: content or fallback // is its pass's call. - // Not an async memo that is a verdict reader only by reading a verdict - // memo (F-6, #4050: it opened no window of its own — REACTIVE_WINDOW, - // whose bit >> 6 is CONFIG_VERDICT's) when that verdict memo goes - // pending: re-derived, it is served the committed value it already - // derived from (A10) and asks that question again. What it read ahead is - // in flight, so it is pending through the flight like any derivation - // (F-9) and the hold's work until the flight lands, reading plainly - // (D-25, #3884): it asks once, at the landing. A sync one re-derives as - // before — asking nothing, it stays the lane's. (`_inFlight` is its last - // pass's flight, landed or not; a sync pass clears it.) if ( - (sub._config &= ~(pendingSource && sub._x?._inFlight - ? el._config & (~sub._flags >> 6) & CONFIG_VERDICT - : 0)) & - (CONFIG_REDERIVE | CONFIG_VERDICT) || + sub._config & (CONFIG_REDERIVE | CONFIG_VERDICT) || (status === STATUS_PENDING && link._gen !== sub._depGen) ) { enqueueSub(sub); diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index eddd98dd83..3bab2de944 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -69,10 +69,9 @@ export const REACTIVE_SCREEN_READ = 1 << 18; * the probe's answer (lanes.ts). Per pass. */ export const REACTIVE_PROBE_UNANSWERED = 1 << 19; /** The pass opened a verdict window of its own (`isPending`/`latest`) — not - * only read a verdict memo (F-6, core.ts `read`): a verdict memo it reads - * going pending changes its answer (async.ts `propagateStatus`). Per pass, - * kept until the next. CONFIG_VERDICT << 6, which the propagation relies on - * (1 << 20 is lanes.ts's LEFT_SHOWN). */ + * only read a verdict memo (F-6, core.ts `read`). An async memo whose pass + * did not takes no verdict role (core.ts `recompute`, CONFIG_ASYNC). Per + * pass (1 << 20 is lanes.ts's LEFT_SHOWN). */ export const REACTIVE_WINDOW = 1 << 21; // Static configuration bits packed into Owner/Computed/Signal _config. @@ -222,6 +221,12 @@ export const CONFIG_KEY_BAG = 1 << 28; * joins nothing, so two mounts under one boundary made in separate flushes * keep their holds apart (D-4, S-5 rows 1′–3′, #4283). */ export const CONFIG_FRAME = 1 << 23; +/** The node's last pass that answered registered a flight (`handleAsync`): + * an async memo. A sync answer clears it; a pass that threw (NotReady) keeps + * the last answer's. With no verdict window of its own (REACTIVE_WINDOW), it + * takes no verdict role from the verdict memos it reads (core.ts + * `recompute`, F-6). */ +export const CONFIG_ASYNC = 1 << 29; export const STATUS_NONE = 0; export const STATUS_PENDING = 1 << 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 01c16beaac..bc024db045 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -13,6 +13,7 @@ import { import { $REFRESH, CONFIG_AUTO_DISPOSE, + CONFIG_ASYNC, CONFIG_CHILDREN_FORBIDDEN, CONFIG_FRESH_READ, CONFIG_GUESS, @@ -536,6 +537,7 @@ export function recompute(el: Computed, create: boolean = false): void { const selfRegistered = el._x?._inFlight !== prevInFlight; value = selfRegistered || !isAsyncResult ? fnResult : handleAsync(el, fnResult); if (!selfRegistered && !isAsyncResult) { + el._config &= ~CONFIG_ASYNC; if (el._x !== null) el._x._inFlight = null; // A sync (non-object) return is the first real answer; async-shaped // results clear inside handleAsync at their own landing points, and a @@ -612,8 +614,20 @@ export function recompute(el: Computed, create: boolean = false): void { ); // A verdict reader is one for as long as it probes, or reads a memo // that does (`read`): a pass that did neither (REACTIVE_PROBED, the same - // bit) is an ordinary derivation again. - el._config = (el._config & ~CONFIG_VERDICT) | (el._flags & REACTIVE_PROBED); + // bit) is an ordinary derivation again. Not an async memo that only + // read a verdict memo (F-6, #4050 — no window of its own, + // REACTIVE_WINDOW): what it read ahead is the lane's, and its own async + // is a derivation of it, held in that lane (F-6) — it waits on its + // sources like any derivation (F-9), reading them plainly (D-25), and + // asks its next question once, at their landing. A frame reader, it was + // served committed values a hold had moved past (A10) and asked an + // answered question again; re-derived at each verdict source going + // pending, again (#4397). + el._config = + (el._config & ~CONFIG_VERDICT) | + (el._flags & + REACTIVE_PROBED & + (el._config & CONFIG_ASYNC && !(el._flags & REACTIVE_WINDOW) ? 0 : CONFIG_VERDICT)); // REACTIVE_DISPOSED survives too (#3621): the pass may have disposed its // own owner (a memo calling its root's `dispose()`, a cleanup doing so // #3601/#3606), and `disposeChildren` set the flag on this node @@ -629,8 +643,7 @@ export function recompute(el: Computed, create: boolean = false): void { REACTIVE_STAGED_READ | REACTIVE_LANE_READ | REACTIVE_SCREEN_READ | - REACTIVE_PROBE_UNANSWERED | - REACTIVE_WINDOW)) | + REACTIVE_PROBE_UNANSWERED)) | (create ? el._flags & REACTIVE_SNAPSHOT_STALE : 0); context = oldcontext; if (released) for (const node of released) releaseIfSettledUnobserved(node); @@ -977,8 +990,7 @@ function updateIfNecessary(el: Computed): void { REACTIVE_STAGED_READ | REACTIVE_LANE_READ | REACTIVE_SCREEN_READ | - REACTIVE_PROBE_UNANSWERED | - REACTIVE_WINDOW); + REACTIVE_PROBE_UNANSWERED); } export function computed(fn: (prev?: T) => T | PromiseLike | AsyncIterable): Computed; diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index bc4bc6b5be..7b3bff10f4 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -129,3 +129,82 @@ for (const reader of ["m1()", "[isPending(m1), m1()]"] as const) expect(out.slice(1, -1).every(v => v.startsWith("true,"))).toBe(true); } }); + +// Two verdict memos feeding one async memo (#4397 review): `m1` over +// `m0 = ask(1 + latest(s) + s())` and `m0b = ask(10 * (latest(t) + t()))`, +// `setS(2)` then `setT(1)` 12ms later. An async memo that opened no window +// of its own takes no verdict role (core.ts `recompute`): it waits on its +// sources like any derivation (F-9), so no question is asked twice, whether +// `[isPending(m1), m1()]` is read in one render effect or in two (D-25). +async function twoVerdicts(grouped: boolean) { + const [s, setS] = createSignal(1); + const [t, setT] = createSignal(0); + const asks: string[] = []; + const ask = (name: string, v: number) => { + asks.push(`${name}:${v}`); + return sleep(10).then(() => v); + }; + const shown = { pending: "", value: "" }; + createRoot(() => { + const m0 = createMemo(() => ask("m0", 1 + latest(s) + s())); + const m0b = createMemo(() => ask("m0b", 10 * (latest(t) + t()))); + const m1 = createMemo(() => ask("m1", 1 + m0() + m0b())); + createLoadingBoundary( + () => { + if (grouped) + createRenderEffect( + () => [String(isPending(m1)), String(m1())], + ([p, v]) => void Object.assign(shown, { pending: p, value: v }) + ); + else { + createRenderEffect( + () => String(isPending(m1)), + p => void (shown.pending = p) + ); + createRenderEffect( + () => String(m1()), + v => void (shown.value = v) + ); + } + return null; + }, + () => "L" + ); + }); + flush(); + await settle(); + asks.length = 0; + setS(2); + await sleep(12); + flush(); + setT(1); + await settle(); + await settle(); + return { asks, shown }; +} + +it("two verdict memos into one async memo: no question asked twice, grouped or split", async () => { + const grouped = await twoVerdicts(true); + const split = await twoVerdicts(false); + for (const { asks } of [grouped, split]) expect(new Set(asks).size).toBe(asks.length); + expect(grouped.shown).toEqual({ pending: "false", value: "26" }); + expect(split.shown).toEqual(grouped.shown); +}); + +// Open question (lane merge, #4398): with the grouped reader, +// `m1` is pending in the s write's transaction and the render effect reading +// `isPending(m1)` beside `m1()` is held there, so the t write merges into +// that transaction instead of opening its own lane — the t lane's views of +// `m1` (`m1:15`, `m1:16`) are never asked. Split, the t write opens its own +// lane and they are asked. Whether a later write merges into an open +// transaction or opens its own lane must not depend on grouping (D-25, +// D-26; F-6: `[isPending(x), x()]` in one render effect holds like `x()` +// alone). +it.fails( + "two verdict memos into one async memo: grouped asks the t lane's questions as split does", + async () => { + const grouped = await twoVerdicts(true); + const split = await twoVerdicts(false); + expect(grouped.asks).toEqual(split.asks); + } +); From bd4bd824c216e98ff398f75017eeb3820aace7bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:57:53 +0000 Subject: [PATCH 03/11] fix(signals): a grouped reader over a held write shows the lane's answer A reader that groups isPending with the value over a held write (`[isPending(m1), m1()]`) never showed m1's lane answer, though a plain value reader did. Two things kept it out: - A pass served a held node's committed value (frameRead, a stale reader of the hold) still kept the node's propagated pending sources, so when it went pending on another source it never woke when that source settled. frameRead now drops what the served node propagated onto it (A15): the landing re-derives it. - A lane's reveal (reruns on a lane) cleared REACTIVE_FRAME_READ, the mark the held transaction's landing needed to re-run the reader. Only the transaction's own reruns clear it now. CONFIG_ASYNC is worked out once, at the end of each pass, from the pass's `_inFlight` (registered a flight: async; answered sync: not; pending or errored without a flight: the last answer's), so a plain sync recompute does no extra work and handleAsync no longer touches it. refresh(m0) mid-hold now shows the lane's answer for the refreshed question in every reader shape. Pins for the held write, an equal landing, and refresh after and mid-hold cover the value, pair and split readers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/verdict-memo-flight-no-reask.md | 2 + packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/async.ts | 5 +- packages/signals/src/core/constants.ts | 11 +- packages/signals/src/core/core.ts | 21 ++- packages/signals/src/core/scheduler.ts | 6 +- .../verdict-memo-flight-no-reask.test.ts | 129 +++++++++++++++++- scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 14 +- 9 files changed, 168 insertions(+), 24 deletions(-) diff --git a/.changeset/verdict-memo-flight-no-reask.md b/.changeset/verdict-memo-flight-no-reask.md index 45e2b6cd9c..3d06e54c57 100644 --- a/.changeset/verdict-memo-flight-no-reask.md +++ b/.changeset/verdict-memo-flight-no-reask.md @@ -3,3 +3,5 @@ --- An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question twice. Reading such a memo made the async memo a verdict reader too (F-6). So it was re-derived each time one of its verdict sources went pending, and it was served committed values that a hold had already moved past. Each time, it asked a question it had already answered. With two verdict sources, it could ask a stale question after both had landed. An async memo that opens no `isPending()` or `latest()` window of its own now takes no verdict role. It waits on its sources like any other derivation and asks each question once. Sync memos are unchanged. + +A reader that groups `isPending()` with the value over a held write, such as `[isPending(m1), m1()]`, now shows the lane's answer as a plain value reader does. Two things kept it from doing so. A pass that was served a held memo's committed value still waited on that memo's flight, so it never woke when the source it was really pending on settled. And a lane's reveal cleared the stale-reader mark that the held transaction's landing needed in order to re-run it. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index c663142b6a..3e7231ae2a 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:213` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×15 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×2 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×4 lanes.ts×4 scheduler.ts×15 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×2 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 verdict-memo-flight-no-reask.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:221` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 verdict.ts×1 map.ts×1 optimistic.ts×1 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 643b71c9ac..282c319ba6 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -1,6 +1,5 @@ import { CONFIG_AUTO_DISPOSE, - CONFIG_ASYNC, CONFIG_REDERIVE, CONFIG_GUESS, CONFIG_OVERRIDE, @@ -61,7 +60,7 @@ export function addPendingSource(el: Computed, source: Computed): bool return true; } -function removePendingSource(el: Computed, source: Computed): boolean { +export function removePendingSource(el: Computed, source: Computed): boolean { const sources = el._x?._pendingSources; if (!sources?.delete(source)) return false; if (!sources.size) el._x!._pendingSources = undefined; @@ -427,7 +426,6 @@ export function handleAsync( } if (!thenable && !iterator) { - el._config &= ~CONFIG_ASYNC; if (el._x !== null) el._x._inFlight = null; // A sync landing is the first real answer for a loadingValue node. el._loading = false; @@ -463,7 +461,6 @@ export function handleAsync( // fired _flightTeardown. A future non-recompute registration path must // release it here before overwriting _inFlight. ext(el)._inFlight = result as PromiseLike | AsyncIterable; - el._config |= CONFIG_ASYNC; // The question this flight answers (A18 provenance, lanes.ts): the action // whose slice asked it; mainline is always the current question. el._x!._q = question || MAINLINE_QUESTION; diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 3bab2de944..b8ca8a906a 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -221,11 +221,12 @@ export const CONFIG_KEY_BAG = 1 << 28; * joins nothing, so two mounts under one boundary made in separate flushes * keep their holds apart (D-4, S-5 rows 1′–3′, #4283). */ export const CONFIG_FRAME = 1 << 23; -/** The node's last pass that answered registered a flight (`handleAsync`): - * an async memo. A sync answer clears it; a pass that threw (NotReady) keeps - * the last answer's. With no verdict window of its own (REACTIVE_WINDOW), it - * takes no verdict role from the verdict memos it reads (core.ts - * `recompute`, F-6). */ +/** The node's last pass that answered registered a flight (`_inFlight`): + * an async memo. A sync answer clears it; a pass that threw (NotReady, or an + * error) keeps the last answer's. Worked out once, at the end of each pass + * (core.ts `recompute`). With no verdict window of its own + * (REACTIVE_WINDOW), it takes no verdict role from the verdict memos it + * reads (F-6). */ export const CONFIG_ASYNC = 1 << 29; export const STATUS_NONE = 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index bc024db045..2f4e6a8fd7 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -6,6 +6,7 @@ import { parkLoadingWindow, releaseFlightTeardown, releaseIfSettledUnobserved, + removePendingSource, settleErroredDependents, settlePendingSource, stageError @@ -537,7 +538,6 @@ export function recompute(el: Computed, create: boolean = false): void { const selfRegistered = el._x?._inFlight !== prevInFlight; value = selfRegistered || !isAsyncResult ? fnResult : handleAsync(el, fnResult); if (!selfRegistered && !isAsyncResult) { - el._config &= ~CONFIG_ASYNC; if (el._x !== null) el._x._inFlight = null; // A sync (non-object) return is the first real answer; async-shaped // results clear inside handleAsync at their own landing points, and a @@ -622,12 +622,19 @@ export function recompute(el: Computed, create: boolean = false): void { // asks its next question once, at their landing. A frame reader, it was // served committed values a hold had moved past (A10) and asked an // answered question again; re-derived at each verdict source going - // pending, again (#4397). + // pending, again (#4397). Async (CONFIG_ASYNC) is the last answering + // pass's: one that registered a flight (`_inFlight`, cleared at the top + // of every pass) is; one that answered sync is not; one left pending or + // errored without a flight keeps the last answer's. Worked out here, so a + // sync pass does no extra work. + const config = el._x?._inFlight + ? el._config | CONFIG_ASYNC + : el._config & ~(el._statusFlags & (STATUS_PENDING | STATUS_ERROR) ? 0 : CONFIG_ASYNC); el._config = - (el._config & ~CONFIG_VERDICT) | + (config & ~CONFIG_VERDICT) | (el._flags & REACTIVE_PROBED & - (el._config & CONFIG_ASYNC && !(el._flags & REACTIVE_WINDOW) ? 0 : CONFIG_VERDICT)); + (config & CONFIG_ASYNC && !(el._flags & REACTIVE_WINDOW) ? 0 : CONFIG_VERDICT)); // REACTIVE_DISPOSED survives too (#3621): the pass may have disposed its // own owner (a memo calling its root's `dispose()`, a cleanup doing so // #3601/#3606), and `disposeChildren` set the flag on this node @@ -1227,6 +1234,12 @@ function frameRead(c: Computed, el: Signal | Computed): boolean { // head), and the pass may then read a guess and become the lane's. if ((t === flushTransaction && !lane && !verdict) || bornInto(c, t)) return false; staleReader(c, t); + // A15: the pass served `el`'s committed value, so it does not wait on + // `el`'s flight: what `el`'s pending propagated onto it before the pass + // (its origins, self included) is not this pass's — the landing re-derives + // it (`reruns`). Kept, a pass that went pending on another source never + // woke when that source settled (#4397). + (el as Computed)._x?._pendingSources?.forEach(s => removePendingSource(c, s)); return true; } diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index f89dd7cf8b..f7da85fa7b 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -1174,7 +1174,11 @@ export function reruns(u: Transaction): unknown { if (r._flags & REACTIVE_DISPOSED || r._statusFlags & STATUS_ERROR) continue; if (r._flags & REACTIVE_FRAME_READ || r._config & CONFIG_VERDICT) { frame ||= (r as any)._type; - r._flags &= ~REACTIVE_FRAME_READ; + // A lane's reveal leaves the mark to the transaction: a reader that + // also read a held node as committed (`staleReader`) still observes + // that flight until it re-runs — cleared here, the transaction landed + // at this seam with the flight up and voided the reveal's run (#4397). + if (!u._lane) r._flags &= ~REACTIVE_FRAME_READ; (r as any)._modified = false; // Its frame is replaced by the re-derivation: a run one of its // children queued this flush (re-run by the same landing) is for the diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index 7b3bff10f4..aa9e71c9cc 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -18,7 +18,8 @@ import { createSignal, flush, isPending, - latest + latest, + refresh } from "../src/index.js"; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); @@ -208,3 +209,129 @@ it.fails( expect(grouped.asks).toEqual(split.asks); } ); + +// Grouping never changes the result (F-6: `[isPending(x), x()]` in one +// render effect holds like `x()` alone). Over a write `h` holds, the grouped +// reader read `h` as a stale reader of the transaction (A15) and the verdict +// lane's reveal re-derived it: the reveal's re-run cleared the frame-read +// mark the transaction's hold counts, so the transaction landed with `h` in +// flight, the landing voided the reveal's run, and `m1`'s lane answer was +// asked and never shown. A lane's reveal now leaves the mark to the +// transaction (`reruns`). And a pass served a held node's committed value +// does not wait on its flight (`servedCommitted`): a reader that went +// pending on a `refresh()` of `m0` mid-hold woke when the re-ask landed, +// instead of waiting on `h` too and skipping `m1`'s lane answer. +async function heldShapes( + f: (l: number, v: number) => number, + script: (setS: (v: number) => void, m0: () => number) => Promise +) { + const shown: Record = {}; + const asked: Record = {}; + for (const reader of ["value", "pair", "split"] as const) { + const [s, setS] = createSignal(1); + const asks: string[] = []; + const values: string[] = []; + const ask = (n: string, v: number) => { + asks.push(`${n}:${v}`); + return sleep(10).then(() => v); + }; + const show = (v: string) => void (values.at(-1) !== v && values.push(v)); + let m0!: () => number; + const dispose = createRoot(dispose => { + m0 = createMemo(() => ask("m0", f(latest(s), s()))); + const m1 = createMemo(() => ask("m1", 100 + m0())); + const h = createMemo(() => (s() === 1 ? 1 : sleep(80).then(() => 1))); + createLoadingBoundary( + () => { + if (reader === "pair") + createRenderEffect( + () => (h(), [isPending(m1), m1()] as const), + ([, v]) => show(String(v)) + ); + else if (reader === "split") { + createRenderEffect( + () => (h(), isPending(m1)), + () => {} + ); + createRenderEffect( + () => (h(), m1()), + v => show(String(v)) + ); + } else + createRenderEffect( + () => (h(), m1()), + v => show(String(v)) + ); + return null; + }, + () => "L" + ); + return dispose; + }); + flush(); + await settle(); + asks.length = 0; + await script(setS, m0); + await settle(); + await settle(); + dispose(); + shown[reader] = values; + asked[reader] = asks; + } + return { shown, asked }; +} + +it("held write: grouped shows m1's lane answer as a value read does", async () => { + const { shown, asked } = await heldShapes( + (l, v) => 1 + l + v, + async setS => setS(2) + ); + expect(shown.value).toEqual(["103", "104", "105"]); + expect(shown.pair).toEqual(shown.value); + expect(shown.split).toEqual(shown.value); + for (const a of Object.values(asked)) expect(a).toEqual(["m0:4", "m1:104", "m0:5", "m1:105"]); +}); + +it("held write landing equal: grouped shows m1's lane answer as a value read does", async () => { + const { shown } = await heldShapes( + (l, v) => l - v + 3, + async setS => setS(2) + ); + expect(shown.value).toEqual(["103", "104", "103"]); + expect(shown.pair).toEqual(shown.value); + expect(shown.split).toEqual(shown.value); +}); + +it("held write, refresh after it settles: grouped shows m1's lane answer as a value read does", async () => { + const { shown } = await heldShapes( + (l, v) => 1 + l + v, + async (setS, m0) => { + setS(2); + await settle(); + await settle(); + refresh(m0); + flush(); + } + ); + expect(shown.value).toEqual(["103", "104", "105"]); + expect(shown.pair).toEqual(shown.value); + expect(shown.split).toEqual(shown.value); +}); + +it("held write, refresh mid-hold: every shape shows m1's lane answer", async () => { + const { shown, asked } = await heldShapes( + (l, v) => 1 + l + v, + async (setS, m0) => { + setS(2); + await sleep(12); + flush(); + refresh(m0); + flush(); + } + ); + expect(shown.value).toEqual(["103", "104", "105"]); + expect(shown.pair).toEqual(shown.value); + expect(shown.split).toEqual(shown.value); + for (const a of Object.values(asked)) + expect(a).toEqual(["m0:4", "m1:104", "m0:4", "m0:5", "m1:105"]); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 11f9bd4ce9..c5658c8294 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": "37.75 KB", - "minified": 115239 + "minified": 115314 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 061b682e9d..88db444c5c 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2675,11 +2675,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 96,978 B unchanged, measured locally (linux) at 31,763 B brotli / 96,976 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ 178605cf): recorded minified 97,009 -> 97,024 B (+15 B minified over next's record), measured locally (linux) at 31,774 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, over next @ 6b792a95, after #4355; re-recorded over next @ b68942ca): cap 31.80 KB unchanged, recorded minified 96,978 -> 97,023 B (+45 B minified over next's record, measured locally on linux; every boundary-carrying scenario pays the same +38–52 B, inside its recorded minified except this one, which sat at its cap). A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,084 B (+39 B minified over next), measured locally (linux) at 31,823 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,174 B (+129 B minified over next), measured locally (linux) at 31,983 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "31.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 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 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 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. - capMinified: 97084, + capMinified: 97174, alias }, { @@ -5475,7 +5475,7 @@ module.exports = [ // Off-screen fallback disposed in the pass that picks content (#4282 / #4268, 2026-10-26, over next @ fbd375d1): 37.72 KB -> 37.75 KB, recorded minified 115,193 -> 115,217 B (+24 B; +24 B over next), (floor-caps.json) measured in CI at 37,737 B brotli. A re-armed Loading disposes a fallback its committed output does not show in that pass, so the fallback's pending render effects stop holding the frame. Size-Exception accepted by the maintainer (2026-10-26). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. // A derived store's first landing is loading, not pending (async-gen campaign 1, E3 store twin, 2026-10-28, over next @ 6b792a95, after the landing wake): cap 37.75 KB unchanged (brotli already over it on next at 37,978 B; judged on recorded minified), recorded minified 115,217 B unchanged (frozen floor, floor-caps.json untouched), measured locally (linux) at 37,958 B brotli / 115,150 B minified (+17 B over the wake, +17 B over next). A leaf born staged at a never-committed derive's landing answered `isPending` true for one pass; `nodeValue` now links such a leaf and serves its staging with no verdict, as for a memo born staged (F-9, D-30), and the get trap's absent-key path no longer reads the node before `nodeValue` does. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 115,217 B unchanged, measured locally (linux) at 37,931 B brotli / 115,134 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,239 B in floor-caps.json (+22 B minified over next, 2 B past the 20 B allowance), measured locally (linux) at 38,112 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,314 B in floor-caps.json (+97 B minified over next's record, 77 B past the 20 B allowance), measured locally (linux) at 38,081 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5777,11 +5777,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 123,182 B unchanged, measured locally (linux) at 41,948 B brotli / 123,182 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,203 B (+12 B minified over next's record), measured locally (linux) at 41,872 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 41.56 KB unchanged, recorded minified 123,182 -> 123,228 B (+46 B minified over next's record), measured locally (linux) at 41,856 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,285 B (+61 B minified over next), measured locally (linux) at 41,889 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,361 B (+137 B minified over next), measured locally (linux) at 41,988 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "41.56 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. - capMinified: 123285, + capMinified: 123361, alias: pageAlias }, { @@ -6167,11 +6167,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 140,442 B unchanged, measured locally (linux) at 47,474 B brotli / 140,439 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,463 B (+12 B minified over next's record), measured locally (linux) at 47,529 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 47.16 KB unchanged, recorded minified 140,442 -> 140,485 B (+43 B minified over next's record), measured locally (linux) at 47,463 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 47.16 KB unchanged, recorded minified 140,485 -> 140,556 B (+71 B minified over next), measured locally (linux) at 47,552 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opened no window of its own goes pending through that memo's flight instead of being re-derived onto the committed value it already derived from, so it asks each question once (F-6, F-9, D-25, A10). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 47.16 KB unchanged, recorded minified 140,485 -> 140,635 B (+150 B minified over next), measured locally (linux) at 47,573 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "47.16 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. - capMinified: 140556, + capMinified: 140635, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From e924ac4ad5481ace9249f5ec7a5ec114a380b40a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:10:20 +0000 Subject: [PATCH 04/11] test(signals): name frameRead in the held-shapes pin comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/tests/verdict-memo-flight-no-reask.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index aa9e71c9cc..53d5ba9919 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -218,7 +218,7 @@ it.fails( // flight, the landing voided the reveal's run, and `m1`'s lane answer was // asked and never shown. A lane's reveal now leaves the mark to the // transaction (`reruns`). And a pass served a held node's committed value -// does not wait on its flight (`servedCommitted`): a reader that went +// does not wait on its flight (`frameRead`): a reader that went // pending on a `refresh()` of `m0` mid-hold woke when the re-ask landed, // instead of waiting on `h` too and skipping `m1`'s lane answer. async function heldShapes( From ee0b5358370e394408305dcadcd473471a52e821 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:34:04 +0000 Subject: [PATCH 05/11] chore(size): re-record this PR's four sizes over next @ 7ebd4a32 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 | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index c5658c8294..eb21549c7b 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": "37.75 KB", - "minified": 115314 + "minified": 115423 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 88db444c5c..8c515fb30a 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2675,11 +2675,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 96,978 B unchanged, measured locally (linux) at 31,763 B brotli / 96,976 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ 178605cf): recorded minified 97,009 -> 97,024 B (+15 B minified over next's record), measured locally (linux) at 31,774 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, over next @ 6b792a95, after #4355; re-recorded over next @ b68942ca): cap 31.80 KB unchanged, recorded minified 96,978 -> 97,023 B (+45 B minified over next's record, measured locally on linux; every boundary-carrying scenario pays the same +38–52 B, inside its recorded minified except this one, which sat at its cap). A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,174 B (+129 B minified over next), measured locally (linux) at 31,983 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,315 B (+129 B minified over next's measured 97,186 B), measured locally (linux) at 31,931 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "31.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 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 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 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. - capMinified: 97174, + capMinified: 97315, alias }, { @@ -5475,7 +5475,7 @@ module.exports = [ // Off-screen fallback disposed in the pass that picks content (#4282 / #4268, 2026-10-26, over next @ fbd375d1): 37.72 KB -> 37.75 KB, recorded minified 115,193 -> 115,217 B (+24 B; +24 B over next), (floor-caps.json) measured in CI at 37,737 B brotli. A re-armed Loading disposes a fallback its committed output does not show in that pass, so the fallback's pending render effects stop holding the frame. Size-Exception accepted by the maintainer (2026-10-26). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. // A derived store's first landing is loading, not pending (async-gen campaign 1, E3 store twin, 2026-10-28, over next @ 6b792a95, after the landing wake): cap 37.75 KB unchanged (brotli already over it on next at 37,978 B; judged on recorded minified), recorded minified 115,217 B unchanged (frozen floor, floor-caps.json untouched), measured locally (linux) at 37,958 B brotli / 115,150 B minified (+17 B over the wake, +17 B over next). A leaf born staged at a never-committed derive's landing answered `isPending` true for one pass; `nodeValue` now links such a leaf and serves its staging with no verdict, as for a memo born staged (F-9, D-30), and the get trap's absent-key path no longer reads the node before `nodeValue` does. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 115,217 B unchanged, measured locally (linux) at 37,931 B brotli / 115,134 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,314 B in floor-caps.json (+97 B minified over next's record, 77 B past the 20 B allowance), measured locally (linux) at 38,081 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,423 B in floor-caps.json (+119 B minified over next's measured 115,304 B; +206 B over next's record), measured locally (linux) at 38,147 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5777,11 +5777,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 123,182 B unchanged, measured locally (linux) at 41,948 B brotli / 123,182 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,203 B (+12 B minified over next's record), measured locally (linux) at 41,872 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 41.56 KB unchanged, recorded minified 123,182 -> 123,228 B (+46 B minified over next's record), measured locally (linux) at 41,856 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,361 B (+137 B minified over next), measured locally (linux) at 41,988 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,466 B (+136 B minified over next's measured 123,330 B), measured locally (linux) at 42,066 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "41.56 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. - capMinified: 123361, + capMinified: 123466, alias: pageAlias }, { @@ -6167,11 +6167,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 140,442 B unchanged, measured locally (linux) at 47,474 B brotli / 140,439 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,463 B (+12 B minified over next's record), measured locally (linux) at 47,529 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 47.16 KB unchanged, recorded minified 140,442 -> 140,485 B (+43 B minified over next's record), measured locally (linux) at 47,463 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (2026-10-29, over next @ 957f065d): cap 47.16 KB unchanged, recorded minified 140,485 -> 140,635 B (+150 B minified over next), measured locally (linux) at 47,573 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): cap 47.16 KB unchanged, recorded minified 140,485 -> 140,728 B (+150 B minified over next's measured 140,578 B), measured locally (linux) at 47,675 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "47.16 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. - capMinified: 140635, + capMinified: 140728, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From c4426aff4404ea2b7e509d328c22eeaecf49381f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:27:29 +0000 Subject: [PATCH 06/11] fix(signals): an async memo over a verdict memo stays a verdict reader (D-25) Review of #4397: the strict rule (an async memo with no `latest`/`isPending` window of its own took no verdict role at all) overrode D-25 ("one that reads a verdict reader's value is ... a verdict reader itself"; "a memo computes under its own posture"). It caused two regressions against next: 1. Memo-extracted `latest()` (`v = createMemo(() => latest(s))`, `m1` async over `v()` and `s()`): m1's pass at the write read the transaction's staging and asked m1:220, the lane re-derived it (m1:120), and the landing asked m1:220 again. It now asks m1:120 then m1:220, once each, as the inline form does (D-22, F-9 exc. 2), showing v120 then v220 (F-6). 2. A reader disposed and remounted while the lane answer was in flight: `[isPending(m1), m1()]` in one effect showed true,4 -> true,5 -> false,6 while `m1()` alone and split showed 4 -> 6. All shapes now show the screen, then the landing (F-6: grouped holds like a lone read; D-26). The strict rule is gone. In its place, D-25's "a node whose fetch a hold is waiting on is that hold's work" (#3884), kept by the verdict engine (verdict.ts `holdWork`, behind `holdWorkHook`, like `observeFlightHook`): - A verdict source going pending does not re-derive an async memo with no window of its own: none of its reads changed, and re-derived it was served the committed value it had already derived from and asked that question again (A10). It waits on the source like any derivation (F-9). Except when its own flight is up outside a lane on a new question of the source's: that re-run supersedes a flight asked of a value the source has moved past (a `refresh()` re-ask is the same question). - A held re-run of such a memo reads plainly, as a pending one already did (#3884): the write that re-ran it is its hold's. - Its async posture (CONFIG_ASYNC) is kept at each pass end in verdict.ts, not computed in core. Kept from the PR, as review asked: the frame read's drop of a served flight's pending (A15; now in lanes.ts `laneServed`, behind a hook, run only for lane work and verdict readers, with no closure), and `reruns` leaving the stale-reader mark to the transaction at a lane's reveal. Pins (tests/verdict-memo-flight-no-reask.test.ts): the memo-extracted and inline forms ask m1:120, m1:220 once and show v110, v120, v220 in every reader shape (D-22, D-25, F-6); a reader remounted mid-flight shows the screen, then the landing, grouped as alone or split (F-6, D-26). Both fail on the previous head. Size: the floors are +14 B minified over next (was +126). The verdict scenarios carry the rule: + isPending/latest +339 B. Ledger lines and the frozen live-server-components floor are re-recorded to the measured sizes (Size-Exception: floor-caps.json 115,423 -> 115,646 B minified, measured 38,197 B brotli; the maintainer relaxed the size rule for this fix). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/docs/RULES-INDEX.md | 4 +- packages/signals/src/core/async.ts | 10 +- packages/signals/src/core/constants.ts | 12 +- packages/signals/src/core/core.ts | 54 ++---- packages/signals/src/core/lanes.ts | 15 +- packages/signals/src/core/scheduler.ts | 19 +- packages/signals/src/core/verdict.ts | 45 ++++- .../verdict-memo-flight-no-reask.test.ts | 165 +++++++++++++++++- scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 17 +- 10 files changed, 275 insertions(+), 68 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 40fb87d560..6c3326e9f5 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -54,12 +54,12 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×4 verdict.ts×2 | createMemo.test.ts×1 error-staged-recovery.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | async.ts×1 core.ts×3 verdict.ts×3 | createMemo.test.ts×1 error-staged-recovery.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 verdict-memo-flight-no-reask.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | | A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:213` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×4 lanes.ts×4 scheduler.ts×15 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×2 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 verdict-memo-flight-no-reask.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×2 constants.ts×1 core.ts×4 lanes.ts×5 scheduler.ts×15 verdict.ts×4 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 show-stale-child-in-action-4137.test.ts×2 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 verdict-memo-flight-no-reask.test.ts×1 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:221` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 verdict.ts×1 map.ts×1 optimistic.ts×1 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 78be3c203d..beb9ba57d5 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -44,6 +44,7 @@ import { schedule, txOf, freshHook, + holdWorkHook, guessing, laneStageHook } from "./scheduler.js"; @@ -988,8 +989,15 @@ export function propagateStatus(el: Computed, status: number, error: any): // Inheriting the error would make a probe's reader errored. A boundary's // output (CONFIG_REDERIVE, boundaries.ts) the same: content or fallback // is its pass's call. + // Not one D-25 makes the hold's work (verdict.ts `holdWork`): an async + // memo with no verdict window of its own reads a verdict memo's value, + // and that source going pending changes none of its reads — re-derived, + // it is served the committed value it already derived from (A10) and + // asks that question again. It waits on the source like any derivation + // (F-9), and asks its next question once, at the landing (#4397). if ( - sub._config & (CONFIG_REDERIVE | CONFIG_VERDICT) || + sub._config & CONFIG_REDERIVE || + (sub._config & CONFIG_VERDICT && !(pendingSource && holdWorkHook!(sub, el))) || (status === STATUS_PENDING && link._gen !== sub._depGen) ) { enqueueSub(sub); diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index b8ca8a906a..f2bfd3124d 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -70,8 +70,8 @@ export const REACTIVE_SCREEN_READ = 1 << 18; export const REACTIVE_PROBE_UNANSWERED = 1 << 19; /** The pass opened a verdict window of its own (`isPending`/`latest`) — not * only read a verdict memo (F-6, core.ts `read`). An async memo whose pass - * did not takes no verdict role (core.ts `recompute`, CONFIG_ASYNC). Per - * pass (1 << 20 is lanes.ts's LEFT_SHOWN). */ + * did not is D-25's hold work while its flight is up (verdict.ts + * `holdWork`). Per pass (1 << 20 is lanes.ts's LEFT_SHOWN). */ export const REACTIVE_WINDOW = 1 << 21; // Static configuration bits packed into Owner/Computed/Signal _config. @@ -221,12 +221,8 @@ export const CONFIG_KEY_BAG = 1 << 28; * joins nothing, so two mounts under one boundary made in separate flushes * keep their holds apart (D-4, S-5 rows 1′–3′, #4283). */ export const CONFIG_FRAME = 1 << 23; -/** The node's last pass that answered registered a flight (`_inFlight`): - * an async memo. A sync answer clears it; a pass that threw (NotReady, or an - * error) keeps the last answer's. Worked out once, at the end of each pass - * (core.ts `recompute`). With no verdict window of its own - * (REACTIVE_WINDOW), it takes no verdict role from the verdict memos it - * reads (F-6). */ +/** The node's last answering pass registered a flight: an async memo. Kept + * by the verdict engine alone (verdict.ts `holdWork`), which reads it. */ export const CONFIG_ASYNC = 1 << 29; export const STATUS_NONE = 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 8305609d49..40206fe87b 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -6,7 +6,6 @@ import { parkLoadingWindow, releaseFlightTeardown, releaseIfSettledUnobserved, - removePendingSource, settleErroredDependents, settlePendingSource, stageError @@ -14,7 +13,6 @@ import { import { $REFRESH, CONFIG_AUTO_DISPOSE, - CONFIG_ASYNC, CONFIG_CHILDREN_FORBIDDEN, CONFIG_FRESH_READ, CONFIG_GUESS, @@ -52,7 +50,6 @@ import { REACTIVE_LANE_DIRTY, REACTIVE_LANE_READ, REACTIVE_PROBE_UNANSWERED, - REACTIVE_WINDOW, REACTIVE_SCREEN_READ, REACTIVE_IN_HEAP_HEIGHT, REACTIVE_LAZY, @@ -138,8 +135,10 @@ import { laneReadHook, laneStageHook, laneSeamsHook, + laneServedHook, laneWriteHook, observeFlightHook, + holdWorkHook, wireExternalSourceHook } from "./scheduler.js"; import type { Transaction } from "./scheduler.js"; @@ -379,6 +378,15 @@ export function recompute(el: Computed, create: boolean = false): void { // re-derivation, lane adoption through deps) went with the optimistic engine. if (!create) { deleteFromHeap(el, dirtyQueue); + // A pending flight is the transition's own work (#3884), and so is a + // held async memo's re-run with no verdict window of its own (D-25, + // `holdWork`): a pass that takes no verdict role this time. A probe on + // an earlier pass left CONFIG_VERDICT, and a frame read here would serve + // the committed screen, settle the flight and drop the hold — a + // superseding write's verdict would depend on that probe. Cleared for + // this pass, before the flight it judges by is superseded; a probe in + // the body re-stamps it after the plain reads. + holdWorkHook?.(el); if (el._x !== null) { el._x._inFlight = null; // Supersede is where an iterator flight dies (#3122): close it now. @@ -398,12 +406,6 @@ export function recompute(el: Computed, create: boolean = false): void { // A). // A boundary's structure (CONFIG_FRAME) is the frame like a render // effect: it publishes mainline outside its transaction's flush (#4283). - // A pending flight is the transition's own work (#3884). A probe on an - // earlier pass left CONFIG_VERDICT, and a frame read here would serve the - // committed screen, settle the flight and drop the hold — a superseding - // write's verdict would depend on that probe. Cleared for this pass; a - // probe in the body re-stamps it after the plain reads. - if (el._statusFlags & STATUS_PENDING) el._config &= ~CONFIG_VERDICT; if (el._config & CONFIG_HELD) { const tx = txOf(el); // Held by a blocked lane: the frame joins nothing — a lane never holds @@ -614,27 +616,10 @@ export function recompute(el: Computed, create: boolean = false): void { ); // A verdict reader is one for as long as it probes, or reads a memo // that does (`read`): a pass that did neither (REACTIVE_PROBED, the same - // bit) is an ordinary derivation again. Not an async memo that only - // read a verdict memo (F-6, #4050 — no window of its own, - // REACTIVE_WINDOW): what it read ahead is the lane's, and its own async - // is a derivation of it, held in that lane (F-6) — it waits on its - // sources like any derivation (F-9), reading them plainly (D-25), and - // asks its next question once, at their landing. A frame reader, it was - // served committed values a hold had moved past (A10) and asked an - // answered question again; re-derived at each verdict source going - // pending, again (#4397). Async (CONFIG_ASYNC) is the last answering - // pass's: one that registered a flight (`_inFlight`, cleared at the top - // of every pass) is; one that answered sync is not; one left pending or - // errored without a flight keeps the last answer's. Worked out here, so a - // sync pass does no extra work. - const config = el._x?._inFlight - ? el._config | CONFIG_ASYNC - : el._config & ~(el._statusFlags & (STATUS_PENDING | STATUS_ERROR) ? 0 : CONFIG_ASYNC); - el._config = - (config & ~CONFIG_VERDICT) | - (el._flags & - REACTIVE_PROBED & - (config & CONFIG_ASYNC && !(el._flags & REACTIVE_WINDOW) ? 0 : CONFIG_VERDICT)); + // bit) is an ordinary derivation again. Its async posture is the + // verdict engine's to keep (verdict.ts `holdWork`, D-25). + el._config = (el._config & ~CONFIG_VERDICT) | (el._flags & REACTIVE_PROBED); + holdWorkHook?.(el, el); // REACTIVE_DISPOSED survives too (#3621): the pass may have disposed its // own owner (a memo calling its root's `dispose()`, a cleanup doing so // #3601/#3606), and `disposeChildren` set the flag on this node @@ -1238,12 +1223,9 @@ function frameRead(c: Computed, el: Signal | Computed): boolean { // head), and the pass may then read a guess and become the lane's. if ((t === flushTransaction && !lane && !verdict) || bornInto(c, t)) return false; staleReader(c, t); - // A15: the pass served `el`'s committed value, so it does not wait on - // `el`'s flight: what `el`'s pending propagated onto it before the pass - // (its origins, self included) is not this pass's — the landing re-derives - // it (`reruns`). Kept, a pass that went pending on another source never - // woke when that source settled (#4397). - (el as Computed)._x?._pendingSources?.forEach(s => removePendingSource(c, s)); + // Lane work or a verdict reader no longer waits on `el`'s flight (lanes.ts + // `laneServed`, A15). + if (lane || verdict) laneServedHook!(c, el); return true; } diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index b65eff8d19..a4c56e29a4 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -61,6 +61,7 @@ import { import { attrHooks } from "./attribution-hooks.js"; import { creatorReads, ext, stagedRead, tracking } from "./core.js"; import { NotReadyError } from "./error.js"; +import { removePendingSource } from "./async.js"; import { laneValueOf } from "./truth.js"; import { enqueueSub } from "./heap.js"; import { @@ -978,6 +979,17 @@ const laneSeams = (leaks: Computed[] | null): void => { enqueueSub(r); } }; +/** A15: lane work or a verdict reader was served `el`'s committed value + * (core.ts `frameRead`), so its pass does not wait on `el`'s flight: what + * `el`'s pending propagated onto it before the pass (its origins, `el` + * included) is not this pass's — the landing re-derives it (`reruns`). + * Kept, a pass that went pending on another source never woke when that + * source settled (#4397). Only a reader something made pending has anything + * to drop. */ +const laneServed = (c: Computed, el: Signal | Computed): void => { + const from = (el as Computed)._x?._pendingSources; + if (from && c._x?._pendingSources) for (const s of from) removePendingSource(c, s); +}; /** Render effects that read a verdict and entered a lane before its seam (#4284). */ const entered: Computed[] = []; installLaneHooks( @@ -989,7 +1001,8 @@ installLaneHooks( laneSeams, () => lanes.length !== 0 && laneCorrections(), endLanes, - lanesBlocked + lanesBlocked, + laneServed ); if (__OBSERVE__) GlobalQueue._laneGuesses = t => { diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index e61c20e173..7f27be649d 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -80,6 +80,10 @@ export let laneSeamsHook: ((leaks: Computed[] | null) => void) | null = nul export let laneCorrectionsHook: (() => boolean) | null = null; export let endLanesHook: ((u: Transaction) => void) | null = null; export let lanesBlockedHook: ((t: Transaction) => boolean) | null = null; +/** core.ts `frameRead` served lane work or a verdict reader a held node's + * committed value (lanes.ts `laneServed`). */ +export let laneServedHook: ((c: Computed, el: Signal | Computed) => void) | null = + null; /** A guess was written (lanes.ts `optimisticWrite`): the arms only a guess * reaches (CONFIG_GUESS) — a build without `createOptimistic` or * `createOptimisticStore` never sets it, and its bundler folds them. */ @@ -96,7 +100,8 @@ export function installLaneHooks( seams: NonNullable, corrections: NonNullable, end: NonNullable, - blocked: NonNullable + blocked: NonNullable, + served: NonNullable ): void { laneReadHook = read; laneStageHook = stage; @@ -107,6 +112,7 @@ export function installLaneHooks( laneCorrectionsHook = corrections; endLanesHook = end; lanesBlockedHook = blocked; + laneServedHook = served; } // Boundaries (boundaries.ts): the display consumers between an observer // and the root. `catch` — status from a frame reader, nearest boundary @@ -168,8 +174,17 @@ export function installMarkHooks( } /** Verdicts (verdict.ts). */ export let observeFlightHook: ((c: Computed, el: Computed) => boolean) | null = null; -export function installVerdictHooks(observeFlight: NonNullable): void { +/** D-25's hold work (verdict.ts `holdWork`): at a pass's head, `n` alone — + * drops a verdict role the pass does not take; at a pass's end, `n` twice — + * keeps its async posture; 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; +export function installVerdictHooks( + observeFlight: NonNullable, + holdWork: NonNullable +): void { observeFlightHook = observeFlight; + holdWorkHook = holdWork; } /** 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 a53bf1783e..d6fe505587 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -12,6 +12,7 @@ import { CONFIG_CHILDREN_FORBIDDEN, CONFIG_GUESS, + CONFIG_ASYNC, CONFIG_HELD, CONFIG_INPUTS_PUBLISHED, CONFIG_OVERRIDE, @@ -24,6 +25,7 @@ import { REACTIVE_JOINED, REACTIVE_LANE_READ, REACTIVE_PROBED, + REACTIVE_REASK, REACTIVE_PROBE_UNANSWERED, REACTIVE_STAGED_READ, REACTIVE_WINDOW, @@ -502,10 +504,51 @@ function verdictValue(el: Signal | Computed, c: Computed | null): return el._value; } +/** D-25: a node whose fetch a hold is waiting on is that hold's work, not a + * verdict reader — a pass that re-runs it reads plainly — and a memo's + * posture is what it reads: one that reads a verdict reader's value is a + * verdict reader itself (#4050). An async memo with no verdict window of its + * own (REACTIVE_WINDOW: it read a verdict memo, it called neither `latest` + * nor `isPending`) is a verdict reader between its flights, and the hold's + * work while one is up or its hold re-runs it. + * + * At a pass's head (`source` absent): a pending node's pass — its flight is + * the transition's own work (#3884) — and a held one's re-run of such a memo + * read plainly: the write that re-ran it is its hold's, and served the + * screen it re-asked a question it had answered (A10, #4397). + * + * On `source` going pending (`propagateStatus`): such a memo waits on it + * like any derivation (F-9) instead of re-deriving — what it read changed + * no value, so its re-run re-asked the question it was answering. Unless + * its own flight is up outside a lane on a new question of the source's: + * its re-run supersedes a flight asked of a value the source has moved past + * (re-asked by a `refresh()`, the source's question is the same one). */ +function holdWork(n: Computed, source?: Computed): unknown { + // At a pass's end (`source` is the node): async (CONFIG_ASYNC) is the + // last answering pass's — one that registered a + // flight (`_inFlight`) is; one that answered sync is not; one left pending + // or errored without a flight keeps the last answer's. + if (source === n) { + if (n._x?._inFlight) n._config |= CONFIG_ASYNC; + else if (!(n._statusFlags & (STATUS_PENDING | STATUS_ERROR))) n._config &= ~CONFIG_ASYNC; + return; + } + const own = n._config & CONFIG_ASYNC && !(n._flags & REACTIVE_WINDOW); + if (source) + return ( + own && + (!(n._statusFlags & STATUS_PENDING) || + n._x!._transaction?._lane || + source._flags & REACTIVE_REASK) + ); + if (n._statusFlags & STATUS_PENDING || (own && n._config & CONFIG_HELD)) + n._config &= ~CONFIG_VERDICT; +} + // 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); +installVerdictHooks(observeFlight, holdWork); /** A store read inside an untracked `isPending` probe found a live mark on * a record with no node to carry it (store/affects.ts): not final. */ GlobalQueue._witnessMark = () => { diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index 53d5ba9919..3d7ba23eee 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -1,8 +1,9 @@ -// A memo that reads a verdict memo is a verdict reader (F-6, #4050) without -// opening a window of its own. When that verdict memo goes pending, what the -// memo read ahead is in flight: it is pending through the flight like any -// derivation (F-9) and the hold's work until it lands (D-25, #3884), so an -// async one asks its next question once, at the landing. Before: `m1` over +// A memo that reads a verdict memo is a verdict reader (D-25, #4050) without +// opening a window of its own. When that verdict memo goes pending, none of +// what an async one read changed: it waits on it like any derivation (F-9), +// and a pass its hold re-runs is the hold's work, reading plainly (D-25, +// #3884, verdict.ts `holdWork`), so it asks its next question once, at the +// landing. Before: `m1` over // `m0 = ask(1 + latest(s) + s())` was re-derived when `m0` re-asked, served // `m0`'s committed value (A10) — the one it had already derived `m1:5` from — // and asked `m1:5` again; that flight held the verdict lane, `m0`'s landing @@ -134,9 +135,10 @@ for (const reader of ["m1()", "[isPending(m1), m1()]"] as const) // Two verdict memos feeding one async memo (#4397 review): `m1` over // `m0 = ask(1 + latest(s) + s())` and `m0b = ask(10 * (latest(t) + t()))`, // `setS(2)` then `setT(1)` 12ms later. An async memo that opened no window -// of its own takes no verdict role (core.ts `recompute`): it waits on its -// sources like any derivation (F-9), so no question is asked twice, whether -// `[isPending(m1), m1()]` is read in one render effect or in two (D-25). +// of its own waits on a verdict source going pending like any derivation +// (F-9), and its hold's re-run of it reads plainly (D-25, verdict.ts +// `holdWork`), so no question is asked twice, whether `[isPending(m1), m1()]` +// is read in one render effect or in two (D-25). async function twoVerdicts(grouped: boolean) { const [s, setS] = createSignal(1); const [t, setT] = createSignal(0); @@ -335,3 +337,150 @@ it("held write, refresh mid-hold: every shape shows m1's lane answer", async () for (const a of Object.values(asked)) expect(a).toEqual(["m0:4", "m1:104", "m0:4", "m0:5", "m1:105"]); }); + +// The `latest()` read extracted into a sync memo (D-22): `v` is a verdict +// memo, and `m1`, an async memo over `v()` and `s()`, reads its value, so it +// is a verdict reader itself (D-25, #4050), as the inline `latest(s)` makes +// it. The write's verdict lane asks `m1:120` (`v`'s lane value, the +// screen's `s()`), revealed ahead of the transaction (F-6: v120 then v220), +// and the landing asks `m1:220` — each once, as inline. Before: an async +// memo with no window of its own took no verdict role, so its pass at the +// write read the transaction's staging and asked `m1:220`, the lane +// re-derived it on the screen (`m1:120`), and the landing asked `m1:220` +// again (F-9: a re-ask of the same question is no new question). A later +// write before the landing is a new question, asked once. +async function extracted(reader: "value" | "pair" | "split", second: boolean, inline = false) { + const [s, setS] = createSignal(1); + const [u, setU] = createSignal(0); + const asks: string[] = []; + const values: string[] = []; + const show = (x: string) => void (values.at(-1) !== x && values.push(x)); + const dispose = createRoot(dispose => { + const v = inline ? () => latest(s) : createMemo(() => latest(s)); + const m1 = createMemo(() => { + const q = v() * 10 + s() * 100 + u(); + asks.push(`m1:${q}`); + return sleep(10).then(() => q); + }); + createLoadingBoundary( + () => { + if (reader === "pair") + createRenderEffect( + () => [isPending(m1), m1()] as const, + ([, x]) => show(`v${x}`) + ); + else if (reader === "split") { + createRenderEffect( + () => isPending(m1), + () => {} + ); + createRenderEffect(m1, x => show(`v${x}`)); + } else createRenderEffect(m1, x => show(`v${x}`)); + return null; + }, + () => "L" + ); + return dispose; + }); + flush(); + await settle(); + asks.length = 0; + setS(2); + flush(); + if (second) { + await sleep(2); + flush(); + setU(5); + flush(); + } + await settle(); + await settle(); + dispose(); + return { asks, values }; +} + +for (const reader of ["value", "pair", "split"] as const) + it(`${reader}: an async memo over a latest() memo asks each question once, as inline`, async () => { + for (const inline of [false, true]) { + const one = await extracted(reader, false, inline); + expect(one.asks).toEqual(["m1:120", "m1:220"]); + expect(one.values).toEqual(["v110", "v120", "v220"]); + const two = await extracted(reader, true, inline); + expect(two.asks).toEqual(["m1:120", "m1:125", "m1:225"]); + expect(two.values).toEqual(["v110", "v125", "v225"]); + } + }); + +// A reader disposed and mounted again while the write's lane answer is in +// flight: mounted mid-hold, it shows what is on the screen and reveals with +// the hold (D-26), and grouping `[isPending(m1), m1()]` in one render effect +// holds like `m1()` alone (F-6). Before: `m1`, no verdict reader, kept the +// lane flight its reader had left, which landed after the lane ended — the +// grouped remount showed `true,5`, a lane answer the screen never showed. +async function remounted(reader: "value" | "pair" | "split", later: boolean) { + const [s, setS] = createSignal(1); + const [u, setU] = createSignal(0); + const asks: string[] = []; + const out: string[] = []; + const ask = (n: string, v: number) => { + asks.push(`${n}:${v}`); + return sleep(10).then(() => v); + }; + let unmount!: () => void; + const mount = () => + createRoot(d => { + unmount = d; + createLoadingBoundary( + () => { + const push = (x: string) => void out.push(x); + if (reader === "pair") createRenderEffect(() => `${isPending(m1)},${m1()}`, push); + else if (reader === "split") { + createRenderEffect(() => `p${isPending(m1)}`, push); + createRenderEffect(() => `v${m1()}`, push); + } else createRenderEffect(() => `v${m1()}`, push); + return null; + }, + () => "L" + ); + }); + let m1!: () => number; + const dispose = createRoot(dispose => { + const m0 = createMemo(() => ask("m0", 1 + latest(s) + s())); + m1 = createMemo(() => ask("m1", 1 + m0() + u())); + createMemo(() => (s() === 1 ? 1 : sleep(80).then(() => 1))); + mount(); + return dispose; + }); + flush(); + await settle(); + asks.length = 0; + setS(2); + await sleep(12); + flush(); + unmount(); + flush(); + await sleep(8); + mount(); + flush(); + if (later) setU(5); + await settle(); + await settle(); + unmount(); + dispose(); + return { asks, out }; +} + +for (const later of [false, true]) + it(`remounted mid-flight${later ? ", then a write" : ""}: every shape shows the screen, then the landing`, async () => { + const last = later ? 11 : 6; + const value = await remounted("value", later); + expect(value.out).toEqual(["v4", `v${last}`]); + expect(new Set(value.asks).size).toBe(value.asks.length); + const pair = await remounted("pair", later); + expect(pair.out).toEqual(["false,4", "true,4", `false,${last}`]); + expect(pair.asks).toEqual(value.asks); + const split = await remounted("split", later); + expect(split.out.filter(x => x[0] === "v")).toEqual(value.out); + expect(split.out.filter(x => x[0] === "p").at(-1)).toBe("pfalse"); + expect(split.asks).toEqual(value.asks); + }); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index eb21549c7b..37421a22f3 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": "37.75 KB", - "minified": 115423 + "minified": 115646 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 8c515fb30a..3223ec4f58 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1416,8 +1416,9 @@ module.exports = [ // latest() tears ahead of an optimistic lane whatever the JSX grouping (#4304 / #4284, 2026-10-26, over next @ 2f7dc84c, accepted by the maintainer): 10.30 -> 10.38 KB, recorded minified 28,735 -> 28,817 B (+131 B minified over this PR's base), measured in CI at 10,368 B brotli. A render effect that read a verdict and entered a guess lane the seam had not judged is re-run from the mainline seat when that lane holds (`laneSeams`'s `entered` list), so its `latest()` shows the guess beside the held screen in one effect as in split ones. Correctness; the floors carry none of it. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // An isPending() probe wakes a dormant memo outside its window (#4366, 2026-10-28, over next @ 5f60713a): cap 10.38 KB unchanged, recorded minified 28,817 -> 28,680 B (+4 B minified over next), measured locally (linux) at 10,380 B brotli. A lazy or dormant memo (an unowned memo left with no reader) woken by a read inside `isPending`/`latest` recomputed inside the probe's window, so it derived from its sources' committed values; the wake now runs outside it, as a pull does (A31). Correctness. Recorded minified = measured; CI re-bases. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 28,680 -> 28,693 B, measured locally (linux) at 10,384 B brotli / 28,693 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. + // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 10.38 KB unchanged, recorded minified 28,693 -> 29,082 B (+339 B minified over next's measured 28,743 B), measured locally (linux) at 10,544 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "10.38 KB", - capMinified: 28693, + capMinified: 29082, alias }, { @@ -2675,11 +2676,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 96,978 B unchanged, measured locally (linux) at 31,763 B brotli / 96,976 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ 178605cf): recorded minified 97,009 -> 97,024 B (+15 B minified over next's record), measured locally (linux) at 31,774 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, over next @ 6b792a95, after #4355; re-recorded over next @ b68942ca): cap 31.80 KB unchanged, recorded minified 96,978 -> 97,023 B (+45 B minified over next's record, measured locally on linux; every boundary-carrying scenario pays the same +38–52 B, inside its recorded minified except this one, which sat at its cap). A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured. - // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,315 B (+129 B minified over next's measured 97,186 B), measured locally (linux) at 31,931 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,292 B (+106 B minified over next's measured 97,186 B), measured locally (linux) at 31,873 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "31.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 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 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 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. - capMinified: 97315, + capMinified: 97292, alias }, { @@ -5475,7 +5476,7 @@ module.exports = [ // Off-screen fallback disposed in the pass that picks content (#4282 / #4268, 2026-10-26, over next @ fbd375d1): 37.72 KB -> 37.75 KB, recorded minified 115,193 -> 115,217 B (+24 B; +24 B over next), (floor-caps.json) measured in CI at 37,737 B brotli. A re-armed Loading disposes a fallback its committed output does not show in that pass, so the fallback's pending render effects stop holding the frame. Size-Exception accepted by the maintainer (2026-10-26). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. // A derived store's first landing is loading, not pending (async-gen campaign 1, E3 store twin, 2026-10-28, over next @ 6b792a95, after the landing wake): cap 37.75 KB unchanged (brotli already over it on next at 37,978 B; judged on recorded minified), recorded minified 115,217 B unchanged (frozen floor, floor-caps.json untouched), measured locally (linux) at 37,958 B brotli / 115,150 B minified (+17 B over the wake, +17 B over next). A leaf born staged at a never-committed derive's landing answered `isPending` true for one pass; `nodeValue` now links such a leaf and serves its staging with no verdict, as for a memo born staged (F-9, D-30), and the get trap's absent-key path no longer reads the node before `nodeValue` does. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 115,217 B unchanged, measured locally (linux) at 37,931 B brotli / 115,134 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. - // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,423 B in floor-caps.json (+119 B minified over next's measured 115,304 B; +206 B over next's record), measured locally (linux) at 38,147 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,646 B in floor-caps.json (+342 B minified over next's measured 115,304 B; +429 B over next's record), measured locally (linux) at 38,197 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5777,11 +5778,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 123,182 B unchanged, measured locally (linux) at 41,948 B brotli / 123,182 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,203 B (+12 B minified over next's record), measured locally (linux) at 41,872 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 41.56 KB unchanged, recorded minified 123,182 -> 123,228 B (+46 B minified over next's record), measured locally (linux) at 41,856 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,466 B (+136 B minified over next's measured 123,330 B), measured locally (linux) at 42,066 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,755 B (+425 B minified over next's measured 123,330 B), measured locally (linux) at 42,070 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "41.56 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. - capMinified: 123466, + capMinified: 123755, alias: pageAlias }, { @@ -6167,11 +6168,11 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 140,442 B unchanged, measured locally (linux) at 47,474 B brotli / 140,439 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,463 B (+12 B minified over next's record), measured locally (linux) at 47,529 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 47.16 KB unchanged, recorded minified 140,442 -> 140,485 B (+43 B minified over next's record), measured locally (linux) at 47,463 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (2026-10-29, re-recorded over next @ 7ebd4a32): cap 47.16 KB unchanged, recorded minified 140,485 -> 140,728 B (+150 B minified over next's measured 140,578 B), measured locally (linux) at 47,675 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) takes no verdict role unless it opens a window of its own (`CONFIG_ASYNC`, worked out at the end of each pass), so it waits on its sources and asks each question once (F-6, F-9, D-25, A10). A pass served a held node's committed value no longer waits on that node's flight, and a lane's reveal leaves the stale-reader mark to the transaction, so a grouped reader over a held write shows the lane's answer (A15). Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. + // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 47.16 KB unchanged, recorded minified 140,485 -> 141,024 B (+446 B minified over next's measured 140,578 B), measured locally (linux) at 47,759 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "47.16 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. - capMinified: 140728, + capMinified: 141024, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From 2a1dfeded3331800484e18b5b93113ef6a779bd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:37:28 +0000 Subject: [PATCH 07/11] test(signals): pin the remount shapes on what holds whatever the timing CI ran the "remounted mid-flight, then a write" pin slower than local runs: the landing's `m1:6` was asked before the remount in the grouped run and after the write in the lone run, so comparing one shape's asks against another's compared two timings, not two shapes. Run at a later remount locally, every shape asks `m1:6` alike, and none diverges. The pin now states what holds at any timing, per shape: no question asked twice (F-9), the lane's `m1:5` never shown to a reader mounted mid-hold (D-26), and the grouped `[isPending(m1), m1()]` ending on the landing like the lone read (F-6). The no-write case still fails on the strict rule's head, where the grouped remount showed `true,5`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .../verdict-memo-flight-no-reask.test.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index 3d7ba23eee..9595386a5d 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -470,17 +470,27 @@ async function remounted(reader: "value" | "pair" | "split", later: boolean) { return { asks, out }; } +// Timing decides whether the landing's `m1:6` is asked before the remount +// (and, with a later write, before it), in every shape alike: the pins are +// what holds either way. No question is asked twice; no shape shows the +// lane's `m1:5`, which the screen never showed; each ends on the landing. for (const later of [false, true]) it(`remounted mid-flight${later ? ", then a write" : ""}: every shape shows the screen, then the landing`, async () => { const last = later ? 11 : 6; - const value = await remounted("value", later); - expect(value.out).toEqual(["v4", `v${last}`]); - expect(new Set(value.asks).size).toBe(value.asks.length); - const pair = await remounted("pair", later); - expect(pair.out).toEqual(["false,4", "true,4", `false,${last}`]); - expect(pair.asks).toEqual(value.asks); - const split = await remounted("split", later); - expect(split.out.filter(x => x[0] === "v")).toEqual(value.out); - expect(split.out.filter(x => x[0] === "p").at(-1)).toBe("pfalse"); - expect(split.asks).toEqual(value.asks); + for (const reader of ["value", "pair", "split"] as const) { + const { asks, out } = await remounted(reader, later); + expect(new Set(asks).size).toBe(asks.length); + expect(asks.slice(0, 3)).toEqual(["m0:4", "m1:5", "m0:5"]); + expect(asks.at(-1)).toBe(`m1:${last}`); + const values = out + .map(x => x.slice(x.indexOf(",") + 1).replace(/^[vp]/, "")) + .filter(x => !/^(true|false)$/.test(x)); + expect(values).not.toContain("5"); + expect(values[0]).toBe("4"); + expect(values.at(-1)).toBe(String(last)); + if (reader === "pair") { + expect(out[0]).toBe("false,4"); + expect(out.at(-1)).toBe(`false,${last}`); + } else if (reader === "split") expect(out.filter(x => x[0] === "p").at(-1)).toBe("pfalse"); + } }); From 1e65362917873d233d03c75696a39e36032546cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:15:40 +0000 Subject: [PATCH 08/11] test(signals): land the extracted pin's flights by hand, not by timer CI's coverage run (slower than a plain run) let m1's 10 ms lane flight land before the pin's 2 ms sleep ended, so the later write came after the landing and the run asked m1:220 where the pin expects m1:125. The pin compared two timings, not two behaviours. m1's flights now land only when the test lands them, so the later write always comes while the write's flights are up. The pin's expectations are unchanged (D-25, D-22, F-6, F-9), and it still fails on ee0b5358's source in all three reader shapes (the m1:220, m1:120, m1:220 triple ask). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .../verdict-memo-flight-no-reask.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index 9595386a5d..91edcd391c 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -350,6 +350,16 @@ it("held write, refresh mid-hold: every shape shows m1's lane answer", async () // again (F-9: a re-ask of the same question is no new question). A later // write before the landing is a new question, asked once. async function extracted(reader: "value" | "pair" | "split", second: boolean, inline = false) { + // Flights land only when the test lands them, so the later write always + // comes while the write's flights are up, at any runner speed. + const flights: (() => void)[] = []; + const land = async () => { + for (let i = 0; i < 8; i++) { + flights.splice(0).forEach(f => f()); + await sleep(0); + flush(); + } + }; const [s, setS] = createSignal(1); const [u, setU] = createSignal(0); const asks: string[] = []; @@ -360,7 +370,7 @@ async function extracted(reader: "value" | "pair" | "split", second: boolean, in const m1 = createMemo(() => { const q = v() * 10 + s() * 100 + u(); asks.push(`m1:${q}`); - return sleep(10).then(() => q); + return new Promise(r => flights.push(() => r(q))); }); createLoadingBoundary( () => { @@ -383,18 +393,17 @@ async function extracted(reader: "value" | "pair" | "split", second: boolean, in return dispose; }); flush(); - await settle(); + await land(); asks.length = 0; setS(2); flush(); if (second) { - await sleep(2); + await sleep(0); flush(); setU(5); flush(); } - await settle(); - await settle(); + await land(); dispose(); return { asks, values }; } From bb06dc7df27b8f680949296c49a54a13a8d1f294 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:06:10 +0000 Subject: [PATCH 09/11] fix(signals): only an async memo with no window of its own is hold work (D-25) Review of #4397: REACTIVE_WINDOW was wiped at the pass tail, and both of holdWork's consumers (the pass head and propagateStatus) run between passes, so `own` was CONFIG_ASYNC alone. The rule hit every async verdict memo, including one that calls latest()/isPending() itself, and changed screens against next on the canonical F-6 shape: an async memo over `${isPending(m0)}:${latest(m0)}` never showed the lane's true:2, and one over latest(m0) read through [isPending(m1), m1()] skipped l1 and showed true,l1. CONFIG_ASYNC is now set at the pass end, while the pass's REACTIVE_WINDOW is still up, only for a pass that registered a flight and opened no window; a pass that opened one clears it. A memo that asks is a verdict reader whoever dirties it (D-25, ruled 2026-10-28), unchanged from next. The held re-run arm applies only to memos that never asked; removing it reasks m1:5/m1:104/m1:15 in five pins, so it stays, documented against D-25. Pins: the two own-window shapes, as next shows them (D-25, F-6). The pin file takes the hand-landed flights from the split prepared for review, so no pin races a timer. Ledger re-records dropped: the size ledger is re-recorded once on next after the batch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/verdict-memo-flight-no-reask.md | 4 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/verdict.ts | 22 +- .../verdict-memo-flight-no-reask.test.ts | 231 ++++++++++++++---- scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 13 +- 6 files changed, 201 insertions(+), 73 deletions(-) diff --git a/.changeset/verdict-memo-flight-no-reask.md b/.changeset/verdict-memo-flight-no-reask.md index 3d06e54c57..47177f1907 100644 --- a/.changeset/verdict-memo-flight-no-reask.md +++ b/.changeset/verdict-memo-flight-no-reask.md @@ -2,6 +2,6 @@ "@solidjs/signals": patch --- -An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question twice. Reading such a memo made the async memo a verdict reader too (F-6). So it was re-derived each time one of its verdict sources went pending, and it was served committed values that a hold had already moved past. Each time, it asked a question it had already answered. With two verdict sources, it could ask a stale question after both had landed. An async memo that opens no `isPending()` or `latest()` window of its own now takes no verdict role. It waits on its sources like any other derivation and asks each question once. Sync memos are unchanged. +An async memo that reads a memo using `latest()` or `isPending()` no longer asks the same question twice. Reading such a memo makes the async memo a verdict reader too (D-25). It was re-derived each time one of its verdict sources went pending, served a committed value it had already answered, and asked that question again; with two verdict sources it could ask a stale question after both had landed. Such a memo, one that calls neither `latest()` nor `isPending()` itself, now waits on a verdict source going pending like any derivation, and a re-run its hold makes reads plainly as the hold's work. It asks each question once. An async memo that calls `latest()` or `isPending()` itself is unchanged: it stays a verdict reader and shows the lane's answer ahead (F-6). -A reader that groups `isPending()` with the value over a held write, such as `[isPending(m1), m1()]`, now shows the lane's answer as a plain value reader does. Two things kept it from doing so. A pass that was served a held memo's committed value still waited on that memo's flight, so it never woke when the source it was really pending on settled. And a lane's reveal cleared the stale-reader mark that the held transaction's landing needed in order to re-run it. +A reader that groups `isPending()` with the value over a held write, such as `[isPending(m1), m1()]`, now shows the lane's answer as a plain value reader does. A pass served a held memo's committed value no longer waits on that memo's flight, and a lane's reveal leaves the stale-reader mark the held transaction's landing needs to re-run it. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 2f3d1e0c40..dbdabd918d 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -63,7 +63,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:223` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 verdict.ts×1 map.ts×1 optimistic.ts×1 store.ts×3 | create-deferred-3710.test.ts×1 fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:81` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 l2-fuzz-existing-rules.test.ts×2 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | +| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:151` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×2 | create-deferred-3710.test.ts×2 derived-store-first-landing-verdict.test.ts×1 fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | | A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:410` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | | A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:417` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | | A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:231` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … | diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index d6fe505587..c3fc5c30ea 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -515,7 +515,10 @@ function verdictValue(el: Signal | Computed, c: Computed | null): * At a pass's head (`source` absent): a pending node's pass — its flight is * the transition's own work (#3884) — and a held one's re-run of such a memo * read plainly: the write that re-ran it is its hold's, and served the - * screen it re-asked a question it had answered (A10, #4397). + * screen it re-asked a question it had answered (A10, #4397). Only a memo + * that never asked: D-25's "a verdict posture outlives the pass that asked" + * is a pass that called `latest`/`isPending`, which clears CONFIG_ASYNC at + * its end and stays a verdict reader whoever dirties it. * * On `source` going pending (`propagateStatus`): such a memo waits on it * like any derivation (F-9) instead of re-deriving — what it read changed @@ -524,16 +527,19 @@ function verdictValue(el: Signal | Computed, c: Computed | null): * its re-run supersedes a flight asked of a value the source has moved past * (re-asked by a `refresh()`, the source's question is the same one). */ function holdWork(n: Computed, source?: Computed): unknown { - // At a pass's end (`source` is the node): async (CONFIG_ASYNC) is the - // last answering pass's — one that registered a - // flight (`_inFlight`) is; one that answered sync is not; one left pending - // or errored without a flight keeps the last answer's. + // At a pass's end (`source` is the node), while the pass's + // REACTIVE_WINDOW is still up (the tail wipes it): CONFIG_ASYNC records an + // async memo with no window of its own — the last answering pass + // registered a flight (`_inFlight`) and called neither `latest` nor + // `isPending`. A pass that opened a window, or answered sync, clears it; + // one left pending or errored without a flight keeps the last answer's. if (source === n) { - if (n._x?._inFlight) n._config |= CONFIG_ASYNC; - else if (!(n._statusFlags & (STATUS_PENDING | STATUS_ERROR))) n._config &= ~CONFIG_ASYNC; + if (n._x?._inFlight && !(n._flags & REACTIVE_WINDOW)) n._config |= CONFIG_ASYNC; + else if (n._flags & REACTIVE_WINDOW || !(n._statusFlags & (STATUS_PENDING | STATUS_ERROR))) + n._config &= ~CONFIG_ASYNC; return; } - const own = n._config & CONFIG_ASYNC && !(n._flags & REACTIVE_WINDOW); + const own = n._config & CONFIG_ASYNC; if (source) return ( own && diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index 91edcd391c..9f583d3456 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -31,12 +31,41 @@ async function settle() { } } +// Flights land only when the test lands them, so each step of a pin comes +// at the same point of the write's flights at any runner speed. `land(n)` +// lands what is up, `n` times over (what a landing asks is up next round). +function inFlight() { + const up: (() => void)[] = []; + const land = async (rounds = 8) => { + for (let i = 0; i < rounds; i++) { + up.splice(0).forEach(f => f()); + await sleep(0); + flush(); + } + }; + const fly = (v: T) => new Promise(r => up.push(() => r(v))); + return { land, fly }; +} + +// A memo that holds the write's frame (the boundary's content reads it) until +// the test releases it, after the write's other flights have landed. +function holder(s: () => number) { + let release = () => {}; + const h = createMemo(() => + s() === 1 ? 1 : new Promise(r => void (release = () => r(1))) + ); + return { h, release: () => release() }; +} + const expected = { // The verdict lane answers m0's display-ahead intermediate (`latest(s)` = 2 // beside the screen's `s()` = 1) and m1 over it while `h` holds the write // (F-6: async derived from a display-ahead read is the lane's), then the // landing asks each memo once. "latest held": ["m0:4", "m1:5", "m0:5", "m1:6"], + // Unheld, `m1` never resolved (`m0:3` rejected): #4383 (A19 exc. 1, #4362) + // makes `isPending(m1)` of the lane node throw NotReady, so the Loading + // holds the write like `h` does and this becomes the held shape. "latest unheld": ["m0:4", "m0:5", "m1:6"], "plain held": ["m0:5", "m1:6"], "plain unheld": ["m0:5", "m1:6"] @@ -92,7 +121,8 @@ for (const useLatest of [true, false]) // Settled memos, no boundary hold: the verdict lane shows `m0:4`/`m1:5`, the // write's landing re-asks `m0`, and `m1` waits on it — pending throughout, -// its next question asked once. +// its next question asked once. Before: `m1:5` was asked three times +// (F-9; D-25, verdict.ts `holdWork`). for (const reader of ["m1()", "[isPending(m1), m1()]"] as const) it(`${reader}: a memo over a re-asking verdict memo waits on it, pending`, async () => { const [s, setS] = createSignal(1); @@ -186,6 +216,8 @@ async function twoVerdicts(grouped: boolean) { return { asks, shown }; } +// Before: grouped and split alike asked the t lane's `m1:15` four times +// (F-9; D-25, verdict.ts `holdWork`). it("two verdict memos into one async memo: no question asked twice, grouped or split", async () => { const grouped = await twoVerdicts(true); const split = await twoVerdicts(false); @@ -194,15 +226,14 @@ it("two verdict memos into one async memo: no question asked twice, grouped or s expect(split.shown).toEqual(grouped.shown); }); -// Open question (lane merge, #4398): with the grouped reader, -// `m1` is pending in the s write's transaction and the render effect reading +// Lane merge (#4398): whether a later write merges into an open transaction +// or opens its own lane must not depend on grouping (D-25, D-26; F-6: +// `[isPending(x), x()]` in one render effect holds like `x()` alone). Holds +// on next: grouped and split ask the same questions, the t lane's views of +// `m1` among them. Fails here (#4398): with the grouped reader, `m1` is +// pending in the s write's transaction and the render effect reading // `isPending(m1)` beside `m1()` is held there, so the t write merges into -// that transaction instead of opening its own lane — the t lane's views of -// `m1` (`m1:15`, `m1:16`) are never asked. Split, the t write opens its own -// lane and they are asked. Whether a later write merges into an open -// transaction or opens its own lane must not depend on grouping (D-25, -// D-26; F-6: `[isPending(x), x()]` in one render effect holds like `x()` -// alone). +// that transaction and the t lane's `m1:15`/`m1:16` are never asked. it.fails( "two verdict memos into one async memo: grouped asks the t lane's questions as split does", async () => { @@ -214,35 +245,38 @@ it.fails( // Grouping never changes the result (F-6: `[isPending(x), x()]` in one // render effect holds like `x()` alone). Over a write `h` holds, the grouped -// reader read `h` as a stale reader of the transaction (A15) and the verdict -// lane's reveal re-derived it: the reveal's re-run cleared the frame-read -// mark the transaction's hold counts, so the transaction landed with `h` in -// flight, the landing voided the reveal's run, and `m1`'s lane answer was -// asked and never shown. A lane's reveal now leaves the mark to the -// transaction (`reruns`). And a pass served a held node's committed value -// does not wait on its flight (`frameRead`): a reader that went -// pending on a `refresh()` of `m0` mid-hold woke when the re-ask landed, -// instead of waiting on `h` too and skipping `m1`'s lane answer. +// reader reads `h` as a stale reader of the transaction (A15) and the +// verdict lane's reveal re-derives it; the transaction's landing must re-run +// it (the reveal leaves the frame-read mark to the transaction), and a pass +// served a held node's committed value does not wait on its flight: a reader +// that went pending on a `refresh()` of `m0` mid-hold wakes when the re-ask +// lands, without waiting on `h` too, and shows `m1`'s lane answer. `h` holds +// until the write's other flights have landed. +type Hold = { land: (rounds?: number) => Promise; release: () => void }; async function heldShapes( f: (l: number, v: number) => number, - script: (setS: (v: number) => void, m0: () => number) => Promise + script: (setS: (v: number) => void, m0: () => number, hold: Hold) => Promise ) { const shown: Record = {}; const asked: Record = {}; for (const reader of ["value", "pair", "split"] as const) { + const { land, fly } = inFlight(); const [s, setS] = createSignal(1); const asks: string[] = []; const values: string[] = []; const ask = (n: string, v: number) => { asks.push(`${n}:${v}`); - return sleep(10).then(() => v); + return fly(v); }; const show = (v: string) => void (values.at(-1) !== v && values.push(v)); let m0!: () => number; + let release!: () => void; const dispose = createRoot(dispose => { m0 = createMemo(() => ask("m0", f(latest(s), s()))); const m1 = createMemo(() => ask("m1", 100 + m0())); - const h = createMemo(() => (s() === 1 ? 1 : sleep(80).then(() => 1))); + const hold = holder(s); + const h = hold.h; + release = hold.release; createLoadingBoundary( () => { if (reader === "pair") @@ -271,11 +305,12 @@ async function heldShapes( return dispose; }); flush(); - await settle(); + await land(); asks.length = 0; - await script(setS, m0); - await settle(); - await settle(); + await script(setS, m0, { land, release }); + await land(); + release(); + await land(); dispose(); shown[reader] = values; asked[reader] = asks; @@ -283,10 +318,15 @@ async function heldShapes( return { shown, asked }; } +// Before: `m1:104` was asked three times (F-9; D-25, verdict.ts +// `holdWork`). it("held write: grouped shows m1's lane answer as a value read does", async () => { const { shown, asked } = await heldShapes( (l, v) => 1 + l + v, - async setS => setS(2) + async setS => { + setS(2); + flush(); + } ); expect(shown.value).toEqual(["103", "104", "105"]); expect(shown.pair).toEqual(shown.value); @@ -297,7 +337,10 @@ it("held write: grouped shows m1's lane answer as a value read does", async () = it("held write landing equal: grouped shows m1's lane answer as a value read does", async () => { const { shown } = await heldShapes( (l, v) => l - v + 3, - async setS => setS(2) + async setS => { + setS(2); + flush(); + } ); expect(shown.value).toEqual(["103", "104", "103"]); expect(shown.pair).toEqual(shown.value); @@ -307,10 +350,12 @@ it("held write landing equal: grouped shows m1's lane answer as a value read doe it("held write, refresh after it settles: grouped shows m1's lane answer as a value read does", async () => { const { shown } = await heldShapes( (l, v) => 1 + l + v, - async (setS, m0) => { + async (setS, m0, { land, release }) => { setS(2); - await settle(); - await settle(); + flush(); + await land(); + release(); + await land(); refresh(m0); flush(); } @@ -320,13 +365,16 @@ it("held write, refresh after it settles: grouped shows m1's lane answer as a va expect(shown.split).toEqual(shown.value); }); +// The refresh comes once `m0:4` has landed, with the lane's `m1:104` up. +// Before: `m1:104` was asked four times (F-9; D-25, verdict.ts +// `holdWork`). it("held write, refresh mid-hold: every shape shows m1's lane answer", async () => { const { shown, asked } = await heldShapes( (l, v) => 1 + l + v, - async (setS, m0) => { + async (setS, m0, { land }) => { setS(2); - await sleep(12); flush(); + await land(1); refresh(m0); flush(); } @@ -343,12 +391,9 @@ it("held write, refresh mid-hold: every shape shows m1's lane answer", async () // is a verdict reader itself (D-25, #4050), as the inline `latest(s)` makes // it. The write's verdict lane asks `m1:120` (`v`'s lane value, the // screen's `s()`), revealed ahead of the transaction (F-6: v120 then v220), -// and the landing asks `m1:220` — each once, as inline. Before: an async -// memo with no window of its own took no verdict role, so its pass at the -// write read the transaction's staging and asked `m1:220`, the lane -// re-derived it on the screen (`m1:120`), and the landing asked `m1:220` -// again (F-9: a re-ask of the same question is no new question). A later -// write before the landing is a new question, asked once. +// and the landing asks `m1:220` — each once, as inline (F-9: a re-ask of the +// same question is no new question). A later write before the landing is a +// new question, asked once. async function extracted(reader: "value" | "pair" | "split", second: boolean, inline = false) { // Flights land only when the test lands them, so the later write always // comes while the write's flights are up, at any runner speed. @@ -423,17 +468,18 @@ for (const reader of ["value", "pair", "split"] as const) // A reader disposed and mounted again while the write's lane answer is in // flight: mounted mid-hold, it shows what is on the screen and reveals with // the hold (D-26), and grouping `[isPending(m1), m1()]` in one render effect -// holds like `m1()` alone (F-6). Before: `m1`, no verdict reader, kept the -// lane flight its reader had left, which landed after the lane ended — the -// grouped remount showed `true,5`, a lane answer the screen never showed. +// holds like `m1()` alone (F-6): no shape shows a lane answer the screen +// never showed. The unmount and the remount come once `m0:4` has landed, +// with the lane's `m1:5` up; a later write comes right after the remount. async function remounted(reader: "value" | "pair" | "split", later: boolean) { + const { land, fly } = inFlight(); const [s, setS] = createSignal(1); const [u, setU] = createSignal(0); const asks: string[] = []; const out: string[] = []; const ask = (n: string, v: number) => { asks.push(`${n}:${v}`); - return sleep(10).then(() => v); + return fly(v); }; let unmount!: () => void; const mount = () => @@ -453,36 +499,35 @@ async function remounted(reader: "value" | "pair" | "split", later: boolean) { ); }); let m1!: () => number; + let release!: () => void; const dispose = createRoot(dispose => { const m0 = createMemo(() => ask("m0", 1 + latest(s) + s())); m1 = createMemo(() => ask("m1", 1 + m0() + u())); - createMemo(() => (s() === 1 ? 1 : sleep(80).then(() => 1))); + release = holder(s).release; mount(); return dispose; }); flush(); - await settle(); + await land(); asks.length = 0; setS(2); - await sleep(12); flush(); + await land(1); unmount(); flush(); - await sleep(8); mount(); flush(); if (later) setU(5); - await settle(); - await settle(); + await land(); + release(); + await land(); unmount(); dispose(); return { asks, out }; } -// Timing decides whether the landing's `m1:6` is asked before the remount -// (and, with a later write, before it), in every shape alike: the pins are -// what holds either way. No question is asked twice; no shape shows the -// lane's `m1:5`, which the screen never showed; each ends on the landing. +// No question is asked twice; no shape shows the lane's `m1:5`, which the +// screen never showed; each ends on the landing. for (const later of [false, true]) it(`remounted mid-flight${later ? ", then a write" : ""}: every shape shows the screen, then the landing`, async () => { const last = later ? 11 : 6; @@ -503,3 +548,85 @@ for (const later of [false, true]) } else if (reader === "split") expect(out.filter(x => x[0] === "p").at(-1)).toBe("pfalse"); } }); + +// Own windows. A memo that calls `isPending()`/`latest()` itself is a verdict +// reader (D-25), whatever it returns: `m1`, async, over +// `${isPending(m0)}:${latest(m0)}` asks at the write with `m0`'s flight up +// (`true:1`), when it lands with the write still pending (`true:2`) and at +// the landing (`false:2`), and the lane's `true:2` shows ahead of the +// landing's `false:2` (F-6: async derived from a display-ahead read is the +// lane's). +it("own window: an async memo over isPending() and latest() of an async memo shows the lane's answer ahead", async () => { + const { land, fly } = inFlight(); + const [s, setS] = createSignal(1); + const asks: string[] = []; + const out: string[] = []; + const dispose = createRoot(dispose => { + const m0 = createMemo(() => (asks.push(`m0:${s()}`), fly(s()))); + const m1 = createMemo(() => { + const q = `${isPending(m0)}:${latest(m0)}`; + asks.push(q); + return fly(q); + }); + createLoadingBoundary( + () => { + createRenderEffect(m1, v => void out.push(v)); + return null; + }, + () => "L" + ); + return dispose; + }); + flush(); + await land(); + expect(asks).toEqual(["m0:1", "false:1"]); + expect(out).toEqual(["false:1"]); + asks.length = 0; + setS(2); + flush(); + await land(); + dispose(); + expect(asks).toEqual(["m0:2", "true:1", "true:2", "false:2"]); + expect(out).toEqual(["false:1", "true:2", "false:2"]); +}); + +// `m1`, async, over `latest(m0)` opens a window of its own (D-25): it asks +// `l1` at the write, `m0`'s flight up, and `l2` once it lands, and +// `[isPending(m1), m1()]` in one render effect shows the screen, then `l2`'s +// answer, never pending — `l1`'s answer is the screen's (F-6). +it("own window: an async memo over latest() of an async memo, read with isPending() beside it", async () => { + const { land, fly } = inFlight(); + const [s, setS] = createSignal(1); + const asks: string[] = []; + const out: string[] = []; + const dispose = createRoot(dispose => { + const m0 = createMemo(() => (asks.push(`m0:${s()}`), fly(s()))); + const m1 = createMemo(() => { + const l = latest(m0); + asks.push(`l${l}`); + return fly(l); + }); + createLoadingBoundary( + () => { + createRenderEffect( + () => [isPending(m1), m1()] as const, + ([p, v]) => void out.push(`${p},${v}`) + ); + return null; + }, + () => "L" + ); + return dispose; + }); + flush(); + await land(); + expect(asks).toEqual(["m0:1", "l1"]); + expect(out).toEqual(["false,1"]); + asks.length = 0; + setS(2); + flush(); + await land(); + dispose(); + expect(asks).toEqual(["m0:2", "l1", "l2"]); + expect(out).toEqual(["false,1", "false,2"]); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 37421a22f3..a5df77624f 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": "37.75 KB", - "minified": 115646 + "minified": 115217 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 3223ec4f58..8fb2034567 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1416,9 +1416,8 @@ module.exports = [ // latest() tears ahead of an optimistic lane whatever the JSX grouping (#4304 / #4284, 2026-10-26, over next @ 2f7dc84c, accepted by the maintainer): 10.30 -> 10.38 KB, recorded minified 28,735 -> 28,817 B (+131 B minified over this PR's base), measured in CI at 10,368 B brotli. A render effect that read a verdict and entered a guess lane the seam had not judged is re-run from the mainline seat when that lane holds (`laneSeams`'s `entered` list), so its `latest()` shows the guess beside the held screen in one effect as in split ones. Correctness; the floors carry none of it. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // An isPending() probe wakes a dormant memo outside its window (#4366, 2026-10-28, over next @ 5f60713a): cap 10.38 KB unchanged, recorded minified 28,817 -> 28,680 B (+4 B minified over next), measured locally (linux) at 10,380 B brotli. A lazy or dormant memo (an unowned memo left with no reader) woken by a read inside `isPending`/`latest` recomputed inside the probe's window, so it derived from its sources' committed values; the wake now runs outside it, as a pull does (A31). Correctness. Recorded minified = measured; CI re-bases. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 28,680 -> 28,693 B, measured locally (linux) at 10,384 B brotli / 28,693 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. - // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 10.38 KB unchanged, recorded minified 28,693 -> 29,082 B (+339 B minified over next's measured 28,743 B), measured locally (linux) at 10,544 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "10.38 KB", - capMinified: 29082, + capMinified: 28693, alias }, { @@ -2676,11 +2675,10 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 96,978 B unchanged, measured locally (linux) at 31,763 B brotli / 96,976 B minified (+15 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ 178605cf): recorded minified 97,009 -> 97,024 B (+15 B minified over next's record), measured locally (linux) at 31,774 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, over next @ 6b792a95, after #4355; re-recorded over next @ b68942ca): cap 31.80 KB unchanged, recorded minified 96,978 -> 97,023 B (+45 B minified over next's record, measured locally on linux; every boundary-carrying scenario pays the same +38–52 B, inside its recorded minified except this one, which sat at its cap). A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured. - // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 31.80 KB unchanged, recorded minified 97,045 -> 97,292 B (+106 B minified over next's measured 97,186 B), measured locally (linux) at 31,873 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "31.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 96,989 -> 97,047 B (+58 B minified over next's record), measured locally (linux) at 31,811 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 97,024 -> 97,045 B (+21 B minified over next's record), measured locally (linux) at 31,798 B brotli. Recorded minified = measured; CI re-bases. - capMinified: 97292, + capMinified: 97045, alias }, { @@ -5476,7 +5474,6 @@ module.exports = [ // Off-screen fallback disposed in the pass that picks content (#4282 / #4268, 2026-10-26, over next @ fbd375d1): 37.72 KB -> 37.75 KB, recorded minified 115,193 -> 115,217 B (+24 B; +24 B over next), (floor-caps.json) measured in CI at 37,737 B brotli. A re-armed Loading disposes a fallback its committed output does not show in that pass, so the fallback's pending render effects stop holding the frame. Size-Exception accepted by the maintainer (2026-10-26). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. // A derived store's first landing is loading, not pending (async-gen campaign 1, E3 store twin, 2026-10-28, over next @ 6b792a95, after the landing wake): cap 37.75 KB unchanged (brotli already over it on next at 37,978 B; judged on recorded minified), recorded minified 115,217 B unchanged (frozen floor, floor-caps.json untouched), measured locally (linux) at 37,958 B brotli / 115,150 B minified (+17 B over the wake, +17 B over next). A leaf born staged at a never-committed derive's landing answered `isPending` true for one pass; `nodeValue` now links such a leaf and serves its staging with no verdict, as for a memo born staged (F-9, D-30), and the get trap's absent-key path no longer reads the node before `nodeValue` does. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 115,217 B unchanged, measured locally (linux) at 37,931 B brotli / 115,134 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. - // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): Size-Exception: cap 37.75 KB unchanged, recorded minified 115,217 -> 115,646 B in floor-caps.json (+342 B minified over next's measured 115,304 B; +429 B over next's record), measured locally (linux) at 38,197 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5778,11 +5775,10 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 123,182 B unchanged, measured locally (linux) at 41,948 B brotli / 123,182 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 123,191 -> 123,203 B (+12 B minified over next's record), measured locally (linux) at 41,872 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 41.56 KB unchanged, recorded minified 123,182 -> 123,228 B (+46 B minified over next's record), measured locally (linux) at 41,856 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 41.56 KB unchanged, recorded minified 123,224 -> 123,755 B (+425 B minified over next's measured 123,330 B), measured locally (linux) at 42,070 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "41.56 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. - capMinified: 123755, + capMinified: 123224, alias: pageAlias }, { @@ -6168,11 +6164,10 @@ module.exports = [ // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, 2026-10-28, over next @ b68942ca): cap unchanged, recorded minified 140,442 B unchanged, measured locally (linux) at 47,474 B brotli / 140,439 B minified (+16 B minified over next). A held boundary output, and the boundary trees and outputs above it, re-run outside the hold published the committed fallback back unchanged, so their readers kept the hold's stale run of the content and the landing applied it: an empty hole. The re-derive sits in recompute's mainline-publish arm, the one place every frame node drops a staging. Correctness. // A frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378, #4390, 2026-10-28, re-recorded over next @ cbc0eebd): recorded minified 140,451 -> 140,463 B (+12 B minified over next's record), measured locally (linux) at 47,529 B brotli. Recorded minified = measured; CI re-bases. // An Errored heals when the last reader that caught its error is disposed (#4373, 2026-10-28, re-recorded over next @ b68942ca): cap 47.16 KB unchanged, recorded minified 140,442 -> 140,485 B (+43 B minified over next's record), measured locally (linux) at 47,463 B brotli. A boundary's readers become a reader -> source map, so an error boundary waits on what threw only while a reader that caught it is alive. Correctness (Ryan's ruling on #4373). Recorded minified = measured; CI re-bases. - // An async memo over a verdict memo asks each question once (#4397, 2026-10-29, over next @ 7ebd4a32): cap 47.16 KB unchanged, recorded minified 140,485 -> 141,024 B (+446 B minified over next's measured 140,578 B), measured locally (linux) at 47,759 B brotli. An async memo that reads a verdict memo (one using `latest()`/`isPending()`) and opens no window of its own stays a verdict reader (D-25, #4050), and is D-25's hold work while its flight is up: a verdict source going pending makes it wait like any derivation instead of re-deriving it onto the committed value it already derived from (F-9, A10), and a pass its hold re-runs reads plainly (#3884). The rule lives in the verdict engine (verdict.ts `holdWork`, behind a hook), as does the frame read's drop of a served flight's pending (lanes.ts `laneServed`, A15); the floors carry +14 B. Correctness. The maintainer relaxed the size rule for this fix (size matters only if hello world passes 20 KB brotli, 2026-10-29). Recorded minified = measured; CI re-bases. limit: "47.16 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. - capMinified: 141024, + capMinified: 140485, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From b9b9800a0ecd9bf8908139c548801015f97c2631 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:26:56 +0000 Subject: [PATCH 10/11] perf(signals): keep D-25's async posture off recompute's hot path CONFIG_ASYNC is now set where a flight registers (handleAsync) and cleared by a verdict window (markVerdictReader) or a sync answer, instead of a holdWork call at every pass's end. The pass-head check is inline. holdWork only answers propagateStatus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/async.ts | 10 +++++- packages/signals/src/core/core.ts | 17 ++++++--- packages/signals/src/core/scheduler.ts | 6 ++-- packages/signals/src/core/verdict.ts | 48 ++++++++++---------------- 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index beb9ba57d5..a2a74a62a4 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -1,4 +1,5 @@ import { + CONFIG_ASYNC, CONFIG_AUTO_DISPOSE, CONFIG_REDERIVE, CONFIG_GUESS, @@ -13,6 +14,7 @@ import { EFFECT_USER, NOT_PENDING, REACTIVE_DIRTY, + REACTIVE_WINDOW, REACTIVE_DISPOSED, REACTIVE_RECOMPUTING_DEPS, REACTIVE_REASK, @@ -427,7 +429,10 @@ export function handleAsync( } if (!thenable && !iterator) { - if (el._x !== null) el._x._inFlight = null; + if (el._x !== null) { + el._x._inFlight = null; + el._config &= ~CONFIG_ASYNC; + } // A sync landing is the first real answer for a loadingValue node. el._loading = false; return result as T; @@ -462,6 +467,9 @@ export function handleAsync( // fired _flightTeardown. A future non-recompute registration path must // release it here before overwriting _inFlight. ext(el)._inFlight = result as PromiseLike | AsyncIterable; + // D-25 (verdict.ts `holdWork`): an async memo whose answering pass opened + // no verdict window of its own (`markVerdictReader` clears the bit). + if (!(el._flags & REACTIVE_WINDOW)) el._config |= CONFIG_ASYNC; // The question this flight answers (A18 provenance, lanes.ts): the action // whose slice asked it; mainline is always the current question. el._x!._q = question || MAINLINE_QUESTION; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 40206fe87b..d2bee27051 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -12,6 +12,7 @@ import { } from "./async.js"; import { $REFRESH, + CONFIG_ASYNC, CONFIG_AUTO_DISPOSE, CONFIG_CHILDREN_FORBIDDEN, CONFIG_FRESH_READ, @@ -138,7 +139,6 @@ import { laneServedHook, laneWriteHook, observeFlightHook, - holdWorkHook, wireExternalSourceHook } from "./scheduler.js"; import type { Transaction } from "./scheduler.js"; @@ -386,7 +386,11 @@ export function recompute(el: Computed, create: boolean = false): void { // superseding write's verdict would depend on that probe. Cleared for // this pass, before the flight it judges by is superseded; a probe in // the body re-stamps it after the plain reads. - holdWorkHook?.(el); + if ( + el._statusFlags & STATUS_PENDING || + (el._config & (CONFIG_ASYNC | CONFIG_HELD)) === (CONFIG_ASYNC | CONFIG_HELD) + ) + el._config &= ~CONFIG_VERDICT; if (el._x !== null) { el._x._inFlight = null; // Supersede is where an iterator flight dies (#3122): close it now. @@ -540,7 +544,10 @@ export function recompute(el: Computed, create: boolean = false): void { const selfRegistered = el._x?._inFlight !== prevInFlight; value = selfRegistered || !isAsyncResult ? fnResult : handleAsync(el, fnResult); if (!selfRegistered && !isAsyncResult) { - if (el._x !== null) el._x._inFlight = null; + if (el._x !== null) { + el._x._inFlight = null; + el._config &= ~CONFIG_ASYNC; + } // A sync (non-object) return is the first real answer; async-shaped // results clear inside handleAsync at their own landing points, and a // self-registered flight clears when its own handleAsync lands. @@ -562,6 +569,7 @@ export function recompute(el: Computed, create: boolean = false): void { // keeps serving. If the // node is currently errored the error stays the answer until this // retry can actually run. + el._config &= ~CONFIG_ASYNC; parkLoadingWindow(el, e as NotReadyError); } else { // D-41: a pass of a held node, or one that read a staging of this @@ -617,9 +625,8 @@ export function recompute(el: Computed, create: boolean = false): void { // A verdict reader is one for as long as it probes, or reads a memo // that does (`read`): a pass that did neither (REACTIVE_PROBED, the same // bit) is an ordinary derivation again. Its async posture is the - // verdict engine's to keep (verdict.ts `holdWork`, D-25). + // verdict engine's to keep (CONFIG_ASYNC, verdict.ts `holdWork`, D-25). el._config = (el._config & ~CONFIG_VERDICT) | (el._flags & REACTIVE_PROBED); - holdWorkHook?.(el, el); // REACTIVE_DISPOSED survives too (#3621): the pass may have disposed its // own owner (a memo calling its root's `dispose()`, a cleanup doing so // #3601/#3606), and `disposeChildren` set the flag on this node diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 7f27be649d..b88257fa48 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -174,11 +174,9 @@ export function installMarkHooks( } /** Verdicts (verdict.ts). */ export let observeFlightHook: ((c: Computed, el: Computed) => boolean) | null = null; -/** D-25's hold work (verdict.ts `holdWork`): at a pass's head, `n` alone — - * drops a verdict role the pass does not take; at a pass's end, `n` twice — - * keeps its async posture; on `source` going pending, +/** 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; +export let holdWorkHook: ((n: Computed, source: Computed) => unknown) | null = null; export function installVerdictHooks( observeFlight: NonNullable, holdWork: NonNullable diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index c3fc5c30ea..d0ee5fe388 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -251,6 +251,7 @@ function markVerdictReader(window: number): void { // (`route`); a verdict reader from its next pass. #4341: a memo born in // a newly opened read the screen, where its branch was closed. // Not such a pass: a verdict reader now. + c._config &= ~CONFIG_ASYNC; c._flags |= REACTIVE_PROBED | REACTIVE_WINDOW | @@ -512,13 +513,13 @@ function verdictValue(el: Signal | Computed, c: Computed | null): * nor `isPending`) is a verdict reader between its flights, and the hold's * work while one is up or its hold re-runs it. * - * At a pass's head (`source` absent): a pending node's pass — its flight is - * the transition's own work (#3884) — and a held one's re-run of such a memo - * read plainly: the write that re-ran it is its hold's, and served the - * screen it re-asked a question it had answered (A10, #4397). Only a memo - * that never asked: D-25's "a verdict posture outlives the pass that asked" - * is a pass that called `latest`/`isPending`, which clears CONFIG_ASYNC at - * its end and stays a verdict reader whoever dirties it. + * At a pass's head (core.ts `recompute`): a pending node's pass — its + * flight is the transition's own work (#3884) — and a held one's re-run of + * such a memo read plainly: the write that re-ran it is its hold's, and + * served the screen it re-asked a question it had answered (A10, #4397). + * Only a memo that never asked: D-25's "a verdict posture outlives the pass + * that asked" is a pass that called `latest`/`isPending`, which clears + * CONFIG_ASYNC and stays a verdict reader whoever dirties it. * * On `source` going pending (`propagateStatus`): such a memo waits on it * like any derivation (F-9) instead of re-deriving — what it read changed @@ -526,29 +527,18 @@ function verdictValue(el: Signal | Computed, c: Computed | null): * its own flight is up outside a lane on a new question of the source's: * its re-run supersedes a flight asked of a value the source has moved past * (re-asked by a `refresh()`, the source's question is the same one). */ -function holdWork(n: Computed, source?: Computed): unknown { - // At a pass's end (`source` is the node), while the pass's - // REACTIVE_WINDOW is still up (the tail wipes it): CONFIG_ASYNC records an - // async memo with no window of its own — the last answering pass - // registered a flight (`_inFlight`) and called neither `latest` nor - // `isPending`. A pass that opened a window, or answered sync, clears it; - // one left pending or errored without a flight keeps the last answer's. - if (source === n) { - if (n._x?._inFlight && !(n._flags & REACTIVE_WINDOW)) n._config |= CONFIG_ASYNC; - else if (n._flags & REACTIVE_WINDOW || !(n._statusFlags & (STATUS_PENDING | STATUS_ERROR))) - n._config &= ~CONFIG_ASYNC; - return; - } +function holdWork(n: Computed, source: Computed): unknown { + // CONFIG_ASYNC records an async memo with no window of its own: its last + // answering pass registered a flight (`handleAsync`) and called neither + // `latest` nor `isPending`. A window (`markVerdictReader`) or a sync answer + // clears it; a pass left pending or errored keeps the last answer's. const own = n._config & CONFIG_ASYNC; - if (source) - return ( - own && - (!(n._statusFlags & STATUS_PENDING) || - n._x!._transaction?._lane || - source._flags & REACTIVE_REASK) - ); - if (n._statusFlags & STATUS_PENDING || (own && n._config & CONFIG_HELD)) - n._config &= ~CONFIG_VERDICT; + return ( + own && + (!(n._statusFlags & STATUS_PENDING) || + n._x!._transaction?._lane || + source._flags & REACTIVE_REASK) + ); } // Installed at module evaluation — present exactly when something imports From 0d6ca5a709e4394b045af903672c2bbbb4125140 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:43:00 +0000 Subject: [PATCH 11/11] fix(signals): an error clears D-25's async posture A pass that throws a real error answers with no flight of its own, so a verdict source going pending re-derives the memo instead of holding it on its pre-error value (F-1). Pinned, held and unheld. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/src/core/core.ts | 2 + packages/signals/src/core/verdict.ts | 4 +- .../verdict-memo-flight-no-reask.test.ts | 51 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index d2bee27051..41924c3f1e 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -572,6 +572,8 @@ export function recompute(el: Computed, create: boolean = false): void { el._config &= ~CONFIG_ASYNC; parkLoadingWindow(el, e as NotReadyError); } else { + // An error answers (D-25, verdict.ts `holdWork`): no flight is its own. + if (!notReady) el._config &= ~CONFIG_ASYNC; // D-41: a pass of a held node, or one that read a staging of this // flush, stages its error (`stageError`). if (notReady || !(stagedErr = stageError(el, e, true))) { diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index d0ee5fe388..1be9ec92b2 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -530,8 +530,8 @@ function verdictValue(el: Signal | Computed, c: Computed | null): function holdWork(n: Computed, source: Computed): unknown { // CONFIG_ASYNC records an async memo with no window of its own: its last // answering pass registered a flight (`handleAsync`) and called neither - // `latest` nor `isPending`. A window (`markVerdictReader`) or a sync answer - // clears it; a pass left pending or errored keeps the last answer's. + // `latest` nor `isPending`. A window (`markVerdictReader`), a sync answer or + // an error clears it; a pass left pending keeps the last answer's. const own = n._config & CONFIG_ASYNC; return ( own && diff --git a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts index 9f583d3456..37db6ca6ab 100644 --- a/packages/signals/tests/verdict-memo-flight-no-reask.test.ts +++ b/packages/signals/tests/verdict-memo-flight-no-reask.test.ts @@ -630,3 +630,54 @@ it("own window: an async memo over latest() of an async memo, read with isPendin expect(asks).toEqual(["m0:2", "l1", "l2"]); expect(out).toEqual(["false,1", "false,2"]); }); + +// An error is an answer (F-1): after `m1` throws on `m0`'s 5, its pass left +// no flight of its own, so `m0` going pending again re-derives it (D-25, +// verdict.ts `holdWork`). `refresh(m0)` re-asks `m0:5`, `m1` throws again, +// and the boundary keeps the error, never `m1`'s pre-error value as final. +for (const plain of [false, true]) + it(`error, then refresh(m0) to the same answer: ${plain ? "unheld" : "held"}, the error stays`, async () => { + const { land, fly } = inFlight(); + const [s, setS] = createSignal(1); + const out: string[] = []; + let release = () => {}; + let m0!: () => number; + createRoot(() => { + m0 = createMemo(() => fly(1 + latest(s) + s())); + const m1 = createMemo(() => { + const v = m0(); + if (v === 5) throw new Error("boom"); + return fly(100 + v); + }); + const h = createMemo(() => + plain || s() === 1 ? 1 : new Promise(r => void (release = () => r(1))) + ); + createErrorBoundary( + () => + createLoadingBoundary( + () => { + createRenderEffect( + () => (h(), `${isPending(m1)},${m1()}`), + v => void out.push(v) + ); + return null; + }, + () => "L" + ), + () => (out.push("E"), "E") + ); + }); + flush(); + await land(); + setS(2); + flush(); + await land(2); + release(); + await land(); + const atError = out.length; + expect(out[atError - 1]).toBe("E"); + refresh(m0); + flush(); + await land(); + expect(out.slice(atError)).toEqual([]); + });