Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3f6962a
fix(signals): a first answer landing in the tick a write re-asks it i…
claude Oct 30, 2026
ffbccfc
fix(signals): a first answer landing in the tick a write re-asks it i…
claude Oct 30, 2026
c14801f
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
a300836
test(signals): the seat shadow seats a first answer served in its re-…
claude Oct 30, 2026
3af2ce5
chore(size): raise caps for the same-tick first answer
claude Oct 30, 2026
f7e2812
chore(size): raise the live page's frozen floor for the same-tick fir…
claude Oct 30, 2026
664b29d
Merge the earlier unpublished push of this branch (superseded by the …
claude Oct 30, 2026
ef6a56b
Merge origin/next into claude/project-thread-x1ore2-reask-landing-ans…
claude Oct 30, 2026
28f8596
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
2a667cf
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
47114a0
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
da3234d
fix(signals): serve an answered-then-reasked first answer only to a r…
claude Oct 30, 2026
fbafb14
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
8c98e57
fix(signals): type the parked-on source read in read() and the D3' row
claude Oct 30, 2026
46ae175
chore(size): re-record #4459's caps over next @ 131ceb8
claude Oct 30, 2026
0c06ffc
chore(size): re-record the live page floor's minified for #4459
claude Oct 30, 2026
a083918
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
37d60d1
fix(signals): serve an answered-then-reasked first answer one pass a …
claude Oct 30, 2026
44cdb35
fix(signals): an answered-then-reasked first answer reaches a reader …
claude Oct 30, 2026
f29e98a
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
22a3157
chore(size): re-measure the answered-then-reasked serve over next @ 2…
claude Oct 30, 2026
46bd537
Merge remote-tracking branch 'origin/next' into claude/project-thread…
claude Oct 30, 2026
d3b8515
chore(size): re-measure the answered-then-reasked serve over next @ d…
claude Oct 30, 2026
80a6417
Merge next; take next's size ledger (ledger re-records move to the ba…
claude Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/same-tick-first-answer-reask.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,15 @@ 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.

### D-23. `isPending(x)`: three causes, two exceptions, question-scoped, atomic with the value

**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 — `<Loading><p>{m()}</p><span>{isPending(m) ? "updating" : ""}</span></Loading>` — 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: `<input disabled={isPending(data)}>` 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 — `<Loading><p>{m()}</p><span>{isPending(m) ? "updating" : ""}</span></Loading>` — 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: `<input disabled={isPending(data)}>` 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`.

Expand Down
8 changes: 7 additions & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,13 @@ export function handleAsync<T>(
// 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
Expand Down
47 changes: 47 additions & 0 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1966,6 +1966,46 @@ export function devRead(el: Signal<any> | Computed<any>): 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<any>, el: Signal<any> | Computed<any>, owner: Computed<any>) {
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<T>(el: Signal<T> | Computed<T>): T {
// Shadow (S0): every read is run through `shadowRead`, which re-enters
// here once (the bypass) and compares the slot the table predicts with
Expand Down Expand Up @@ -2181,6 +2221,13 @@ export function read<T>(el: Signal<T> | Computed<T>): 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<any>, 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.
Expand Down
24 changes: 24 additions & 0 deletions packages/signals/src/core/seat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
EFFECT_RENDER,
NOT_PENDING,
REACTIVE_JOINED,
REACTIVE_MISSED_WAKE,
REACTIVE_RECOMPUTING_DEPS,
REACTIVE_STAGED_READ,
STATUS_ERROR,
Expand All @@ -68,6 +69,7 @@ import {
import {
clock,
flushTransaction,
observeFlightHook,
globalQueue,
GlobalQueue,
latestOpened,
Expand Down Expand Up @@ -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<any>)._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
}

Expand Down
Loading
Loading