Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/first-load-lands-into-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A new async memo created over a held update now waits for it (#3800). A memo created while a transition holds a value, whose first pass reads that value and returns a promise, lands its first answer into the transition and reveals at its commit, as the same memo with a synchronous first answer already did. Before, the promise landed on its own and its readers showed the new value beside the rest of the page still on the old one. The transition never waits for the new memo's first load: if it lands after the transition commits, it is its own commit.
84 changes: 42 additions & 42 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t

- **The first-commit frame is decided when the node is created.** A later re-ask never adopts it into a hold: a loading source created before the hold is not the hold's work (the `#2937` loading-source rule — `documentation/plans/size-reduction-carve-step1.md` §15.2, "A loading source is not held"). On `next`.
- **A render effect born held owes no run before the commit that lands it** (#3802): a re-staging pass of an effect that still carries a staged value is not queued, because the commit replays it — lane-reached effects included. [#3814](https://github.com/solidjs/solid/pull/3814), merging.
- **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open.
- **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. The first pass's conditions are the born-held arm's (it read a held node, or — a verdict lane's mount — a staging of the flush, #3851); its transaction is recorded on the node (`_bornIn`) and the landing stages into it if it is still live (`handleAsync` → `liveTx`, `holdNode`). Pinned: `tests/issue-3800-repro.test.ts`, `tests/verdict-mount-first-pass-3851.test.ts` ("an async content memo").
- **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`).

**A boundary that has not shown content owns its subtree — A29's boundary scope, fresh mounts (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance, for a boundary that has not shown content: content under it belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`<Show when={x()}>` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. **Mechanism:** a first pass (`STATUS_UNINITIALIZED`) that reads a hold asks the boundaries up its chain (`GlobalQueue._fresh`: `catchStatus` with a pending status — the same walk as any pending read, so revealed boundaries it passes record it as a reader until the commit). When a loading boundary that has not shown content catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. `isPending` of the boundary's value is false while its fallback shows, as for a boundary showing its fallback over a first load; `latest()` of content under it is A11's, unchanged (the derivation staged in the hold). The `direction-rule-probe` pins are unchanged. **Known gaps** (pinned `it.fails`, the same on next): a boundary mounted by a pass that itself joined the hold (a memo created in the mount that reads the hold) shows its fallback now rather than appearing at the hold's commit; a boundary mounted by a flight's hold, top-level or nested, shows its fallback for a frame at the commit. **Re-arm (2026-10-06, deferred):** the ruling's `on` part — content under a boundary that an `on` change has re-armed, committed content included, belongs to that boundary — is deferred to a separate change pending its ruling; until then a re-arm behaves as on next, and its shapes are pinned `it.fails` with the ruled display. Open: a boundary a verdict reader mounts (`<Show when={latest(x) > 0}>` over an action's write) is created by verdict-lane work, and its first passes are routed into that lane (`recompute`'s first-pass lane rule), whose world is the proposal: it shows the held derivation now rather than its fallback (mount-under-hold `verdict` family; the same on pre-L2 and next). The fix belongs to the first-pass lane rule, with #3835's. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush and from mainline: through a memo, directly, bound; nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; catchers at any depth — unrevealed, revealed, plain, and re-armed in flight; boundaries mounted by the hold; no catcher; `isPending` and `latest` on each; a read made pending before the flush joins its hold, and the #1078, #476/#1674 fuzzer findings; the re-arm shapes, pinned `it.fails`); `tests/loading-on-frame-following-3540.test.ts` (3b, 3c: re-arm over an earlier change's flight or staged write, pinned `it.fails` but for the inside-the-action control); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`.
Expand Down Expand Up @@ -124,6 +124,8 @@ A tracked computation served a node's staged `_pendingValue` — a value a live

**A verdict lane's mount over a staging (amended 2026-10-07, #3851).** Maintainer ruling: "we don't want to add size for these unseen things.. that being said in general transitions can hold on any unready read.. But new memos created don't need to be part of it." A transition holds on any unready read; a memo does not join it because it is new. Inside an action, `<Show when={latest(x) > 0}>` opens ahead of the action (A19, display-ahead) and its condition is verdict-lane work; the `<Loading>` it mounts is a mainline mount (the creator's verdict lane is not inherited). Its content's first pass runs in the action's own flush and reads `x`'s staging before the seam holds it — the same world the transaction is about to hold, so the pass is born held as if it had read the held node, and the boundary shows its fallback beside the committed `x = 0` and `content 1` at the landing (`tests/verdict-mount-first-pass-3851.test.ts`). Only a verdict lane's mount: any other first pass that read a staging of the flush publishes as before and, if the flush parks, re-derives on the committed world (the staging-read rule, L2 statement 3). In particular a boundary a held memo mounts in the same flush is not made the transaction's by its creation — its content does not join, the boundary reports no pending of its own, and it appears with the memo at the commit (`tests/loading-fallback-in-flush-3540.test.ts`, "a boundary mounted by the hold"). An action body's read of an unflushed write is served committed (A28) and never counts.

**Clarified 2026-10-07 (#3800, maintainer):** "I meant new memos that don't read async.. if you are moving from page a to page b.. and page b reads async everything is held even if it is new async (assuming the read doesn't get exclusively caught by a boundary). These are basics." A new memo whose first pass reads the held world is held with it whether its first answer is a value or a promise: an async first answer lands into the transaction while it is live (the direction rule's "a first load derived from a hold lands into it").

### A31. A memo computes under its own lane posture, never its puller's

**Status:** **live** 2026-09-14 (#3442) — stated by the fix; the lane-side twin of A29's "a value derived from the held world is that transaction's work"
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 @@ -29,7 +29,9 @@ import {
flush,
globalQueue,
GlobalQueue,
holdNode,
joinFuture,
liveTx,
MAINLINE_QUESTION,
passLane,
question,
Expand Down Expand Up @@ -464,7 +466,11 @@ export function handleAsync<T>(
} else {
// CARVE 2: the override-covered landing (hold + A18 supersession) and the
// lane-routed landing (derived override, lane effect queue) went with
// the optimistic engine; every landing is the plain setSignal.
// the optimistic engine; every landing is the plain setSignal. A first
// answer born into a hold still live lands into it (A29, #3800).
let t = el._x!._bornIn;
el._x!._bornIn = null;
if (wasUninitialized && t && (t = liveTx(t))) holdNode(el, t);
try {
setSignal(el, () => value);
} catch (e) {
Expand Down
13 changes: 13 additions & 0 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,18 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
if (wasPendingSource && !(el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)))
settlePendingSource(el);
}
// A first pass that went pending where the born-held arm would have held
// its value (it read a held node, or a verdict lane's mount read a staging,
// #3851) is born into that transaction without holding it: the hold never
// waits for a first load (the direction rule), but the answer lands into it
// while it is live (A29, #3800).
if (
create &&
lane === null &&
(joined || (prevLane && el._flags & REACTIVE_STAGED_READ)) &&
el._statusFlags & STATUS_PENDING
)
ext(el)._bornIn = flushTransaction ?? passTx;
// Dependencies are the committed frame's until it is replaced (A30, #3410):
// a pass that staged its value leaves the previous pass's tail linked for
// `commitPendingNode` to trim, so a write to a dependency the committed
Expand Down Expand Up @@ -944,6 +956,7 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension {
_pendingFirstChild: null,
_pendingDisposal: null,
_transaction: null,
_bornIn: null,
_reask: false,
_flushed: NOT_PENDING,
_flushedAt: -1,
Expand Down
10 changes: 8 additions & 2 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ export function joinPassTx(t: Transaction, own?: unknown): void {
export function txOf(n: Signal<any> | Computed<any>): Transaction {
return (n._x!._transaction = resolveTx(n._x!._transaction!));
}
/** `t` resolved through merges while it is still parked or open; null once
* it has landed. */
export function liveTx(t: Transaction): Transaction | null {
t = resolveTx(t);
return transactions.indexOf(t) !== -1 ? t : null;
}
export function resolveTx(t: Transaction): Transaction {
while (t._into !== null) t = t._into;
return t;
Expand Down Expand Up @@ -876,8 +882,8 @@ export class GlobalQueue implements IQueue {
// write; #3528: a display-ahead swap before the count it is shown
// beside). A parked frame's runs wait with its transaction instead: the
// effect phase applies a committed frame.
const u = t !== null ? resolveTx(t) : null;
const parked = u !== null && transactions.indexOf(u) !== -1;
const u = t && liveTx(t);
const parked = u !== null;
// Attribution hook: this flush found its transaction incomplete — its
// writes stay staged, its runs are stashed below. Before the effect
// phase (the lanes' display-ahead runs are the visible acknowledgers);
Expand Down
6 changes: 6 additions & 0 deletions packages/signals/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ export interface NodeExtension {
/** L2 — the transaction holding this node while CONFIG_HELD is set
* (`holdNode`); resolved through merges by `txOf`. */
_transaction: Transaction | null;
/** L2 — the transaction a first pass that went pending was born into (it
* read a held node, or a verdict lane's mount read a staging of the
* flush — the born-held arm's conditions): its first answer lands into it
* while it is live; the hold never waits for the node's first load (the
* direction rule, #3800). Consumed at the landing. */
_bornIn: Transaction | null;
/** The node's current flight re-asks the same question (a `refresh()`,
* A19 exc. 2 / A24): verdict-quiet — `isPending` reads false for it —
* through its landing until the commit. */
Expand Down
Loading
Loading