diff --git a/.changeset/same-tick-first-answer-reask.md b/.changeset/same-tick-first-answer-reask.md new file mode 100644 index 0000000000..5bc1ffb546 --- /dev/null +++ b/.changeset/same-tick-first-answer-reask.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An async memo's first answer that resolves in the same tick as a plain write re-asking it is answered, then re-asked (D-22, F-9): `isPending` of it is `true` while the new question flies, and an identity memo or derived store over it reads the answer through `latest` like the memo itself. diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 1c3bbe4137..810de0280b 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -180,7 +180,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 **From:** F-6, F-10, F-9. **Scenarios:** S-25, S-45. -`latest(x)` answers the held write (the newest value a flush has processed, held or not — for a memo nobody has read, the value the flushes it missed would have given it), the optimistic value where one displays, the arrived truth under a superseded override, and the committed value of a node whose own flight is up (below the async nothing newer exists). A sync derivation of a held source is visible through it (the held recompute is a write path like any other). It throws `NotReadyError` only when no value has been produced — before a first landing, including an async memo with no resolution — in every scope. It returns `T`, never `undefined`. A lane held on async downstream of the read does not paint, and does not hide a value `latest` can already return. A memo behind a fallback, or in a branch not yet attached, has still produced its value, so `latest` returns it — a memo born held included: its plain read throws, and `latest` returns its pending value (F-9). Resolved async never pairs `[false, undefined]`. **A first landing a hold carries is a produced value (clarified 2026-10-28):** an async memo's first answer that lands in the same tick as an action writing its input is held with that action (D-4), and `latest` of it returns that answer while the plain read still throws (F-9); a `Loading` whose only read is `latest` of it shows the answer now (S-40). The frame is whole: the screen's committed input beside the answer to it. **A memo nobody has read has still produced what the flushes it missed would have given it (ruled 2026-10-30, #4474):** `latest` of a lazy memo whose sources have committed values is that value, computed on the committed world (laziness is never observable — #3078, #4366, #4474). An identity memo over an async memo, read for the first time in the flush that re-asks the async memo, shows the async memo's landed answer and is pending on the re-ask, the same screen as with a prior reader of it; a plain read of it in that mount is not display-ahead (D-27): its reader is born held (D-2) and shows the re-ask's answer. **Inside a flush, `latest()` of a staging not yet held is the frame's read like any other (clarified 2026-10-28):** it serves the staging, opens no hold, and leaves a plain read beside it in the same pass reading the staging too (F-10: one frame for every channel); if the flush parks, the pass re-derives once as a verdict reader (D-2, A10). A `latest()` read that manufactured a hold for the frame so its pass could run once made the plain reads after it see the screen in a flush that then committed (#4365). +`latest(x)` answers the held write (the newest value a flush has processed, held or not — for a memo nobody has read, the value the flushes it missed would have given it), the optimistic value where one displays, the arrived truth under a superseded override, and the committed value of a node whose own flight is up (below the async nothing newer exists). A sync derivation of a held source is visible through it (the held recompute is a write path like any other). It throws `NotReadyError` only when no value has been produced — before a first landing, including an async memo with no resolution — in every scope. It returns `T`, never `undefined`. A lane held on async downstream of the read does not paint, and does not hide a value `latest` can already return. A memo behind a fallback, or in a branch not yet attached, has still produced its value, so `latest` returns it — a memo born held included: its plain read throws, and `latest` returns its pending value (F-9). Resolved async never pairs `[false, undefined]`. **A first landing a hold carries is a produced value (clarified 2026-10-28, amended 2026-10-30):** an async memo's first answer that lands in the same tick as a write to its input that a hold carries — an action's, or a plain write re-asking it — is held with it (D-4), and `latest` of it returns that answer while the plain read still throws (F-9); a `Loading` whose only read is `latest` of it shows the answer now (S-40). The frame is whole: the screen's committed input beside the answer to it. **A memo nobody has read has still produced what the flushes it missed would have given it (ruled 2026-10-30, #4474):** `latest` of a lazy memo whose sources have committed values is that value, computed on the committed world (laziness is never observable — #3078, #4366, #4474). An identity memo over an async memo, read for the first time in the flush that re-asks the async memo, shows the async memo's landed answer and is pending on the re-ask, the same screen as with a prior reader of it; a plain read of it in that mount is not display-ahead (D-27): its reader is born held (D-2) and shows the re-ask's answer. **Inside a flush, `latest()` of a staging not yet held is the frame's read like any other (clarified 2026-10-28):** it serves the staging, opens no hold, and leaves a plain read beside it in the same pass reading the staging too (F-10: one frame for every channel); if the flush parks, the pass re-derives once as a verdict reader (D-2, A10). A `latest()` read that manufactured a hold for the frame so its pass could run once made the plain reads after it see the screen in a flush that then committed (#4365). **Collapses:** [A28](./SPEC-ASYNC-SEMANTICS.md#a28-a-write-becomes-visible-at-flush--to-every-channel) (`latest` reads the flushed staged world), [A11](./SPEC-ASYNC-SEMANTICS.md#a11-sync-derivations-of-held-sources-are-visible-through-latestispending), [A7](./SPEC-ASYNC-SEMANTICS.md#a7-resolved-async-never-reads-false-undefined), [A17](./SPEC-ASYNC-SEMANTICS.md#a17-an-active-override-is-the-displayed-value-until-its-transaction-commits-and-the-graphs-value-until-its-own-source-answers) (`latest` returns the arrived value). **Status:** standing. **Deviation today:** a lazy memo released unread at its source's landing (#2934) and woken in a flush that re-asks the source has no value; latest of it returns undefined, a plain read of it is born held. Ruled 2026-10-30: it has the value the missed flush would have given it (laziness is never observable). Fix belongs at the wake, both read orders (#4474). Pinned `it.fails` at the ruled screen in `tests/latest-unread-identity-memo-4474.test.ts`; a memo in a root is never released and already reads as ruled. @@ -188,7 +188,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 **From:** F-9, F-1. **Scenarios:** S-26, S-27. -`isPending(x)` is `true` while any of three causes holds: (i) a write held by a live hold; (ii) the node's own async in flight for a new question; (iii) a fresh value landed but held uncommitted by a hold it is entangled with — and `false` the moment none does. Exception 1: a node with no committed value — a first load, or a memo born held over a held value (D-2) — is loading, not pending. It has no verdict: inside an owner `isPending` of it does what its read does — a first load's `NotReadyError` goes to a boundary or holds the change (F-2); a reader of a memo born held is held with it, or caught by a boundary that has not shown content — and the reader runs at the commit, where the answer is final; unowned it is `false` (D-24). A node with a committed value is judged wherever its readers are: on screen, behind a fallback, or in a branch not yet attached. A verdict on content that may have no committed value is read inside the boundary that owns it — `

{m()}

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

{m()}

{isPending(m) ? "updating" : ""}
` — where its reader is caught with the content; read outside, it holds the change that mounted it. Either way no answer is shown before the commit: `` is never seen enabled over an unjudged value (`packages/web/test/ispending-guard-born-held.spec.tsx`). No verdict is the honest answer: `true` would say "updating" for something never shown, and `false` would read as settled to a guard like `if (!isPending(x)) save(x())`. Exception 2: a re-ask of the same question is quiet; an `affects()` mark is a declared new question and pends exactly the marked data and what derives from it, released when the declaring hold settles (or at flush end, ambient). `[isPending(x), x()]` read in one scope never pairs `true` with the fresh value: a verdict reader sees the screen — in either read order — unless it is a node a hold's fetch is waiting on (D-25). `isPending(() => latest(x))` follows `x`'s own non-quiet async only — on a signal or sync memo the latest form is never pending — so `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value either. A spinner driven by `isPending` fires while the owner's async is still in flight (it does not wait for the hold). **Collapses:** [A19](./SPEC-ASYNC-SEMANTICS.md#a19-ispendingx--the-observable-value-is-not-final-three-causes), [A24](./SPEC-ASYNC-SEMANTICS.md#a24-question-scoped-pending-pending-iff-a-value-change-is-in-flight-or-an-affects-mark-is-live), [A10](./SPEC-ASYNC-SEMANTICS.md#a10-ispendingx-x-is-atomic-within-one-scope), [A8](./SPEC-ASYNC-SEMANTICS.md#a8-ispending--latestx-follows-xs-own-async-only--verdicts-are-per-channel), [A14](./SPEC-ASYNC-SEMANTICS.md#a14-companion-nodes-get-child-lanes-that-do-not-merge-with-the-owner), [A20](./SPEC-ASYNC-SEMANTICS.md#a20-superseded-optimistic-writes-announce-a-store-wide-pending)/[A21](./SPEC-ASYNC-SEMANTICS.md#a21-superseded-the-store-wide-mask) (superseded — the mask is gone), recovery 6.3. **Status:** standing. A probe alone holds nothing; a plain read beside it holds (`optimistic-settle-verdicts.test.ts` #3409, `question-scoped-pending.test.ts` 3.4; #3977). `isPending` of a memo born held, read inside and outside the boundary: `tests/fresh-boundary-born-held-reader.test.ts` (#3977; the engine joins the reader to the hold as the read does, which is the throw's observable effect). **Known deviation (#4469, 2026-10-30):** a verdict reader is re-run at every landing of a hold it observed, even when the landing changes nothing it read; the gate that would skip it needs a deps walk. Measured 2026-10-30: two cheaper gates broke F-9/D-23 (verdict-memo-flight-no-reask) and #4189 (errored-store-probe-settled-3962) pins and failed the frozen floors by +31/+36 B. A plain reader of the same recovered hold is not in this: it shows the committed value for the whole hold and runs once, at the commit, since a recovery to a value equal to the last good one is still a change of answer (D-41, D-9). Pinned in `tests/hold-recovers-landing-reruns-4469.test.ts` (the verdict rows `it.fails`). The same seam reaches a verdict reader of a signal whose writes net to its committed value in the tick an action opens a hold (#4485): row V2 (`stagedScreen`) runs before any netted check, so the reader is listed as the hold's stale reader and re-runs at the landing, and `isPending(s)` reads `true` for the hold although the writes made no proposal (D-8). That is a bug, not this deviation; pinned `it.fails` in `tests/netted-verdict-reader-4485.test.ts`. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 371f741b83..3eefc8c82d 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -584,7 +584,13 @@ export function handleAsync( // If the node was dirtied by a newer write (optimistic override or regular), // skip this stale async result — the upcoming flush will recompute the node // with the new value, creating a fresh Promise that supersedes this one. - if (el._flags & REACTIVE_DIRTY) return; + // Not a first answer (D-22, F-9): it resolved before the write — only its + // reaction ran after — so it is a produced value, and the flush that + // re-asks lands it first (the node is answered, then re-asked). Only + // `latest`/`isPending` tell such a node from one with no value, so the + // landing is the verdict engine's (`holdWorkHook`). + if (el._flags & REACTIVE_DIRTY && !(holdWorkHook && el._statusFlags & STATUS_UNINITIALIZED)) + return; const wasUninitialized = el._statusFlags & STATUS_UNINITIALIZED; landStatus(el); // Attribution hook: lets the engine snapshot state before the landing diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 5d2418358a..bd34b91e96 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1966,6 +1966,46 @@ export function devRead(el: Signal | Computed): void { ); } +/** + * D-22 (amended 2026-10-30), F-9: `el`'s first value was staged this flush + * and `el` (`owner`) went pending on a re-ask in it — an async first answer + * landing in the tick a write re-asks it: answered, then re-asked. A memo + * `c` with no value — waiting on `el` (on the source a read of `el` parks + * on: `el` itself, or the async it derives from — at any depth), or not + * waiting at all (its wait released by the landing, or a first pass) — + * derives from that value, as a flush of the answer alone would have let it + * (whether the landing ran before the re-ask reached `c` is the flush's + * order, not the reader's: D-22, at any depth), then runs + * again (the missed-wake latch) and waits on the re-ask holding it: + * `latest`/`isPending` of it read as of `el`. One pass: every read of it is + * served (the latch marks it); its wait on `el` is dropped, so the re-run + * throws and waits on `el` afresh. One served pass a flush (`_served`): a + * pass that throws anyway, on a read beside it, parks on `el` again, and its + * re-run is not served — served, it would re-run without end. Not a reader + * waiting on another source, a render effect (the frame: its plain read + * throws), lane work + * or a verdict lane's pass (D-37: the screen plus the lane's own guesses), + * or a held node (the staging is the hold's). A plain read of such a memo + * throws as one of a memo with no value; only `latest`/`isPending` tell them + * apart, so it is the verdict engine's (`read` calls it behind + * `observeFlightHook`, on the path where a pending read throws). + */ +function servesStaged(c: Computed, el: Signal | Computed, owner: Computed) { + return ( + passLane === null && + c._statusFlags & owner._statusFlags & STATUS_UNINITIALIZED && + el._pendingValue !== NOT_PENDING && + !(el._config & (CONFIG_HELD | CONFIG_STAGED_ERROR)) && + !(c as any)._type && + (c._flags & REACTIVE_MISSED_WAKE || + (c._pendingValue === NOT_PENDING && + c._x?._served !== clock && + (!(c._statusFlags & STATUS_PENDING) || + c._x?._pendingSources?.delete((owner._x!._error as NotReadyError).source)) && + ((ext(c)._served = clock), (c._flags |= REACTIVE_MISSED_WAKE)))) + ); +} + export function read(el: Signal | Computed): T { // Shadow (S0): every read is run through `shadowRead`, which re-enters // here once (the bypass) and compares the slot the table predicts with @@ -2181,6 +2221,13 @@ export function read(el: Signal | Computed): T { ); } if (!(c._config & CONFIG_CHILDREN_FORBIDDEN) || owner._statusFlags & STATUS_UNINITIALIZED) { + // D-22 (amended 2026-10-30), F-9: an answered-then-reasked first + // answer (`servesStaged`), the verdict engine's — out of line, so + // `read` gains a call only where a pending read would throw. + if (observeFlightHook && servesStaged(c as Computed, el, owner)) { + if (__TEST__) shadowActual(SLOT_P); + return el._pendingValue as T; + } // An untracked read of a pending node still re-runs its reader when // the node settles — unless nobody is reading (`spectating`, #3528) // or the reader is the node itself. diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index 69de9c7552..84eb70803b 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -59,6 +59,7 @@ import { EFFECT_RENDER, NOT_PENDING, REACTIVE_JOINED, + REACTIVE_MISSED_WAKE, REACTIVE_RECOMPUTING_DEPS, REACTIVE_STAGED_READ, STATUS_ERROR, @@ -68,6 +69,7 @@ import { import { clock, flushTransaction, + observeFlightHook, globalQueue, GlobalQueue, latestOpened, @@ -922,6 +924,28 @@ export function slot( if (running) return carry(pred("V4", screen, MARK_ST | MARK_AH, flushTransaction)); return carry(pred("V4", screen)); } + // D-22 (amended 2026-10-30), F-9: a first answer staged this flush and + // re-asked in it — answered, then re-asked. A pass with no value of its + // own, waiting on it (on the source a read of it parks on — the node, + // or the async it derives from, at any depth) or not waiting at all (its + // wait released by the landing, or a first pass), derives from it, once + // a flush (`read`). Not lane work or a verdict lane's pass (D-37). + if ( + observeFlightHook && + cUninit && + uninit && + staged && + !held && + !stagedError && + !isEffect && + !inSeat && + (c!._flags & REACTIVE_MISSED_WAKE || + (c!._pendingValue === NOT_PENDING && + c!._x?._served !== clock && + (!(c!._statusFlags & STATUS_PENDING) || + !!c!._x?._pendingSources?.has(((el as Computed)._x!._error as any).source)))) + ) + return carry(pred(rowBase + "D3'", SLOT_P)); return carry(pred(leaf ? "L5" : rowBase + "D3", SLOT_NR)); // F-2 D-28 / F-2 D-9 } diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 4012b192f6..3c15060f04 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -86,6 +86,9 @@ export interface NodeExtension { _error: unknown; _blocked: boolean | undefined; _pendingSources: Set> | undefined; + /** The clock of this node's pass served an answered-then-reasked first + * answer (D-22, F-9; `read`): one served pass a flush. */ + _served?: number; _unobserved: (() => void) | undefined; _snapshotValue: any; /** L2 — the previous pass's frame, parked for this node's commit: its child diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 6138eba09c..5c3dcea075 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -240,7 +240,6 @@ function heldNotFinal(owner: Computed): boolean { /** `isPending`'s answer for an unheld node whose flight is up (cause ii). */ function pendingVerdict(c: Computed | null, el: Computed): void { - if (el._statusFlags & STATUS_UNINITIALIZED) return; if (probing && !quietPending(el)) probeFound = true; if (c && tracking) { if (el._config & CONFIG_HELD) verdictRead(c, txOf(el)); @@ -540,7 +539,14 @@ function verdictValue(el: Signal | Computed, c: Computed | null): return staged(el); } } else if (el._pendingValue !== NOT_PENDING) { - if (owner._statusFlags & STATUS_PENDING && !uninitialized) + // A first value staged this flush and re-asked in it (an async first + // answer landing in the tick a write re-asks it) is answered: the + // re-ask is a new question (F-9, D-22). An error is no value (D-41). + // `latest` serves the staging below. + if ( + owner._statusFlags & STATUS_PENDING && + (!uninitialized || (probing && !(el._config & CONFIG_STAGED_ERROR))) + ) pendingVerdict(tracked ? c : null, owner); else if (tracked && globalQueue._running) provisionalVerdict(c!, !latestActive && !uninitialized); diff --git a/packages/signals/tests/answered-then-reasked-released-reader.test.ts b/packages/signals/tests/answered-then-reasked-released-reader.test.ts new file mode 100644 index 0000000000..a6494e9591 --- /dev/null +++ b/packages/signals/tests/answered-then-reasked-released-reader.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + latest +} from "../src/index.js"; + +// D-22 (amended 2026-10-30), F-9, async generator E2 (3289 #310, 91501 #1993, +// #2609): an async memo answered, then re-asked in one flush. When the +// memo's input is read through `latest()` (or through a sync memo), the +// write's re-ask reaches the memo after its landing has run, and the landing +// released the identity memo's wait: it runs with no value and waits on +// nothing. It still derives from the answer — which of the two the flush ran +// first is the flush's order, not the reader's — so `latest` of it shows +// what `latest` of the memo shows. +const task = () => new Promise(r => setTimeout(r, 0)); + +async function run(shape: "latest" | "superseded" | "via-memo", view: "memo" | "identity") { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(shape === "via-memo" ? 2 : 1); + const flight = (v: number) => new Promise(resolve => asks.push({ v, resolve })); + let m: () => number; + if (shape === "via-memo") { + const m0 = createMemo(() => latest(s) + 1); + m = createMemo(() => flight(m0() + 1)); + } else m = createMemo(() => flight(latest(s) + 1)); + const x = view === "memo" ? m : createMemo(() => m()); + let leaf = ""; + let output = ""; + createRoot(() => { + const b = createLoadingBoundary( + () => { + createRenderEffect( + () => `l:${latest(x)}`, + v => { + leaf = v; + } + ); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + output = v; + }); + }); + const settle = (pick: number) => { + const a = asks.splice(pick % asks.length, 1)[0]; + a.resolve(a.v); + }; + flush(); + if (shape === "superseded") { + // A second question while the first is in flight; its answer lands in + // the tick a write asks a third. + setS(2); + await Promise.resolve(); + settle(1); + setS(1); + } else { + await task(); + flush(); + settle(0); + setS(0); + } + await task(); + flush(); + return output === "content" ? leaf : output; +} + +describe("an answered-then-reasked first answer reaches a reader its landing released", () => { + for (const [shape, shown] of [ + ["latest", "l:2"], + ["superseded", "l:3"], + ["via-memo", "l:4"] + ] as const) + it(`${shape}: an identity memo shows what the memo shows`, async () => { + expect(await run(shape, "memo")).toBe(shown); + expect(await run(shape, "identity")).toBe(shown); + }); +}); diff --git a/packages/signals/tests/answered-then-reasked-same-tick.test.ts b/packages/signals/tests/answered-then-reasked-same-tick.test.ts new file mode 100644 index 0000000000..cbff9c9452 --- /dev/null +++ b/packages/signals/tests/answered-then-reasked-same-tick.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest +} from "../src/index.js"; + +// D-22 (amended 2026-10-30), F-9: an async memo's first answer resolves, and +// in the same synchronous turn a plain write re-asks it; the promise reaction +// runs after the write, so the landing and the re-ask are processed in one +// flush. The answer exists from when it resolved: the memo is answered, then +// re-asked — `latest` shows the answer, the re-ask is a new question +// (`isPending` true), and a derivation of the memo reads the same (async +// generator E2/E3, `t S5 s0=2 t` and `t S6 s0=0 t`) — at every depth: an +// identity memo of an identity memo reads as the memo does (D-22). +const task = () => new Promise(r => setTimeout(r, 0)); + +async function sameTick( + view: "memo" | "identity" | "identity2" | "store", + probe: "pending" | "latest" +) { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const m = createMemo(() => { + const v = 1 + s(); + return new Promise(resolve => asks.push({ v, resolve })); + }); + let x: () => number = m; + if (view === "identity") x = createMemo(() => m()); + else if (view === "identity2") { + const i = createMemo(() => m()); + x = createMemo(() => i()); + } else if (view === "store") { + const [st] = createStore<{ v?: number }>(d => { + d.v = m(); + }, {}); + x = () => st.v!; + } + let leaf = ""; + let output = ""; + createRoot(() => { + const b = createLoadingBoundary( + () => { + createRenderEffect( + () => (probe === "pending" ? `p:${isPending(x)}` : `l:${latest(x)}`), + v => { + leaf = v; + } + ); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + output = v; + }); + }); + const shown = () => (output === "content" ? leaf : output); + flush(); + await task(); + flush(); + expect(shown()).toBe("fallback"); + // `m:2` resolves; before its reaction runs, `s = 2` asks `m:3`. + const first = asks.shift()!; + first.resolve(first.v); + setS(2); + await task(); + flush(); + expect(asks.map(a => a.v)).toEqual([3]); + return shown(); +} + +describe("a first answer landing in the tick a plain write re-asks it", () => { + for (const view of ["memo", "identity", "identity2", "store"] as const) { + it(`${view}: isPending is true — the re-ask is a new question (F-9)`, async () => { + expect(await sameTick(view, "pending")).toBe("p:true"); + }); + it(`${view}: latest shows the answer (D-22)`, async () => { + expect(await sameTick(view, "latest")).toBe("l:2"); + }); + } +}); + +// The derivation's pass that reads the answer is served it once: a second read +// in the same pass is served too, and its re-run waits on the re-ask — also +// when the pass first stopped on another source still loading. +it("a derivation reading the answer twice, or beside a source still loading, settles", async () => { + const asks: { v: number; resolve: (v: number) => void }[] = []; + const [s, setS] = createSignal(1); + const m = createMemo(() => { + const v = 1 + s(); + return new Promise(resolve => asks.push({ v, resolve })); + }); + let resolveOther!: (v: number) => void; + const other = createMemo(() => new Promise(r => (resolveOther = r))); + const twice = createMemo(() => m() + m()); + const beside = createMemo(() => m() + other()); + const seen: string[] = []; + const shown = (probe: () => string, name: string) => { + const b = createLoadingBoundary( + () => { + createRenderEffect(probe, v => void seen.push(`${name} ${v}`)); + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, () => {}); + }; + createRoot(() => { + shown(() => `${latest(twice)} ${isPending(twice)}`, "twice"); + shown(() => `${latest(beside)} ${isPending(beside)}`, "beside"); + }); + flush(); + await task(); + flush(); + const first = asks.shift()!; + first.resolve(first.v); + setS(2); + await task(); + flush(); + expect(asks.map(a => a.v)).toEqual([3]); + expect(seen).toEqual(["twice 4 true"]); + resolveOther(10); + await task(); + flush(); + // `beside` never derived a value: its pass over the answer stopped on + // `other`, and its re-run waits on the re-ask. + expect(seen).toEqual(["twice 4 true"]); + const next = asks.shift()!; + next.resolve(next.v); + await task(); + flush(); + expect(seen).toEqual(["twice 4 true", "twice 6 false", "beside 13 false"]); +}); diff --git a/packages/web/test/async-gen/e2-released-reader.spec.ts b/packages/web/test/async-gen/e2-released-reader.spec.ts new file mode 100644 index 0000000000..f7402f4fd0 --- /dev/null +++ b/packages/web/test/async-gen/e2-released-reader.spec.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment jsdom + */ +// async-gen E2/E3 (3289 #310, 91501 #1993, 91501 #2609; D-22 amended +// 2026-10-30, F-9): an async memo answered, then re-asked in one flush, with +// its input read through `latest()`. The landing ran before the re-ask +// reached the memo, so it released the identity memo's (and the derived +// store's) wait, and the served-only-while-waiting rule left them on +// `[ROOT-L]` while the plain form showed the answer. +import { expect, test } from "vitest"; +import fc from "fast-check"; +import { runScenario } from "./run.js"; +import { scenarioArb } from "./scenario.js"; + +for (const [seed, i] of [ + [3289, 310], + [91501, 1993], + [91501, 2609] +] as [number, number][]) + test(`${seed} #${i}: an identity memo and a derived store show what the memo shows`, async () => { + const sc = fc.sample(scenarioArb(), { seed, numRuns: i + 1 })[i]; + const a = await runScenario(sc); + for (const variant of [{ identity: true }, { store: true }]) { + const b = await runScenario(sc, variant); + const at = (r: typeof a) => r.screens.filter((_, j) => a.settled[j] && b.settled[j]); + expect(at(b), JSON.stringify(variant)).toEqual(at(a)); + } + }); diff --git a/packages/web/test/async-gen/reask-served-once-a-flush.spec.ts b/packages/web/test/async-gen/reask-served-once-a-flush.spec.ts new file mode 100644 index 0000000000..a3a3826f32 --- /dev/null +++ b/packages/web/test/async-gen/reask-served-once-a-flush.spec.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ +// async-gen 91501 #749, minimized (#4459 follow-up; D-22 amended 2026-10-30, +// F-9): `m0` is answered, then re-asked in one flush, and a boundary's +// children memo reads two `Show`s that both park on `m0`. Its first read of +// the first `Show`'s output is served the answer; its read of the second +// throws, so the pass parks on `m0` again with no value of its own. Served +// on every re-run, the missed-wake latch re-ran it in the same flush without +// end (a synchronous loop). One served pass a flush: the re-run throws and +// waits on `m0`. +import { expect, test } from "vitest"; +import { runScenario } from "./run.js"; +import type { Scenario } from "./scenario.js"; + +// [s0=3 m0=async(s0 !%4=2) m1=async(m0 !%4=1)] +// +// :: s0=2 S1 t S0 s0=1 +const sc: Scenario = { + signals: [3], + memos: [ + { async: true, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: 2 }, + { async: true, deps: [{ src: { k: "m", i: 0 }, how: "plain" }], poison: 1 } + ], + tree: { + t: "group", + kids: [ + { + t: "show", + when: { how: "plain", src: { k: "m", i: 1 } }, + child: { t: "leaf", text: [], attrs: [], grouped: false }, + fallback: true + }, + { + t: "show", + when: { how: "plain", src: { k: "m", i: 0 } }, + child: { t: "leaf", text: [], attrs: [], grouped: false }, + fallback: true + } + ] + }, + steps: [ + { t: "write", s: 0, v: 2 }, + { t: "settle", pick: 1 }, + { t: "task" }, + { t: "settle", pick: 0 }, + { t: "write", s: 0, v: 1 } + ] +}; + +test("a reader served an answered-then-reasked first answer is served one pass a flush", async () => { + for (const variant of [{}, { identity: true }, { store: true }, { split: true }]) { + const r = await runScenario(sc, variant); + expect(r.screens, JSON.stringify(variant)).toEqual(Array(6).fill("[ROOT-L]")); + } +}); diff --git a/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts b/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts new file mode 100644 index 0000000000..1171852731 --- /dev/null +++ b/packages/web/test/async-gen/same-tick-first-answer-reask.spec.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + */ +// async-gen rerun 2026-10-30, seed 3289 (E2, E3; D-22 amended 2026-10-30, +// F-9): an async memo's first answer resolves, and in the same synchronous +// turn a plain write re-asks it. The promise reaction runs after the write, +// so the landing and the re-ask are processed in one flush. The answer +// exists from when it resolved: `m0` is answered, then re-asked. `latest` +// shows the answer, the re-ask is a new question (`isPending` true), and an +// identity memo or a derived store over `m0` reads the same. The plain form +// showed `pm0:false`; the identity and store forms stayed on `[ROOT-L]`. +import { expect, test } from "vitest"; +import { runScenario } from "./run.js"; +import type { Scenario } from "./scenario.js"; + +const scenario = (s0: number, how: "pending" | "latest", pick: number, write: number) => + ({ + signals: [s0], + memos: [{ async: true, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: null }], + tree: { + t: "leaf", + text: [], + attrs: [{ how, src: { k: "m", i: 0 } }], + grouped: how === "pending" + }, + steps: [{ t: "task" }, { t: "settle", pick }, { t: "write", s: 0, v: write }, { t: "task" }] + }) as Scenario; + +for (const [name, sc, final] of [ + // [s0=1 m0=async(s0)] :: t S5 s0=2 t + ["isPending", scenario(1, "pending", 5, 2), ""], + // [s0=2 m0=async(s0)] :: t S6 s0=0 t + ["latest", scenario(2, "latest", 6, 0), ""] +] as const) + test(`${name}: a first answer landing in the tick a write re-asks it is answered`, async () => { + for (const variant of [{}, { identity: true }, { store: true }]) { + const r = await runScenario(sc, variant); + expect(r.screens.at(-1), JSON.stringify(variant)).toBe(final); + } + });