Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0479053
fix(signals): a fresh Loading over a held value shows its fallback in…
ryansolid Oct 6, 2026
4c6d570
fix(signals): the first-pass catch asks only the nearest loading boun…
ryansolid Oct 6, 2026
ce4106e
Merge remote-tracking branch 'origin/next' into fix/l2-3540-fresh-loa…
ryansolid Oct 6, 2026
4a95514
fix(signals): ask the nearest loading boundary through _catch; no new…
ryansolid Oct 6, 2026
2d2c282
Merge remote-tracking branch 'origin/next' into fix/l2-3540-fresh-loa…
ryansolid Oct 6, 2026
a661e91
chore(size): Size-Exception for #3824 — five caps at CI-measured + 10…
ryansolid Oct 6, 2026
3f05444
Merge remote-tracking branch 'origin/next' into fix/l2-3540-fresh-loa…
ryansolid Oct 6, 2026
15eb1e9
Merge remote-tracking branch 'origin/next' into fix/l2-3540-fresh-loa…
ryansolid Oct 6, 2026
2755903
Merge remote-tracking branch 'origin/next' into fix/l2-3540-fresh-loa…
ryansolid Oct 6, 2026
3ab71b0
chore(size): reset #3824's caps from CI with the recorded minified (#…
ryansolid Oct 6, 2026
431c2dd
proto(signals): A29's boundary scope — a boundary that has not shown …
ryansolid Oct 6, 2026
a965717
proto(signals): an owned held computation re-derives at the read (#3540)
ryansolid Oct 6, 2026
1e9a1f1
Merge remote-tracking branch 'origin/next' into proto/3540-boundary-s…
ryansolid Oct 6, 2026
7439347
fix(signals): A29's boundary scope — ownership decided at the seam, b…
ryansolid Oct 6, 2026
60d2f1d
docs(signals): regenerate RULES-INDEX without local probe files (#3540)
ryansolid Oct 6, 2026
3e4b13b
fix(signals): rebuild #3540's L2 fix on revision 1; defer re-arm
ryansolid Oct 7, 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/fresh-loading-fallback-in-flush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A fresh `Loading` mounted over a held value shows its fallback in a flush too (#3540 under L2): a first pass a loading boundary that has not shown content catches is the boundary's, not the tick's, so the mount publishes and the content reveals at the commit. Content bound by a render effect under the boundary is collected, so the boundary no longer reveals empty content.
4 changes: 4 additions & 0 deletions documentation/solid-2.0/05-async-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Importantly, `Loading` is intended to cover **branch readiness**: it handles a s

Nested `Loading` boundaries can be used to avoid blocking large subtrees and to control where loading UI appears.

A `Loading` that has not shown content yet owns what is under it. Content there that reads a value a still-running change holds (a write inside an `action`, data another change is loading) is not part of that change: the boundary shows its fallback now, and the content appears when the change commits. The change does not wait for it. The exception is a boundary mounted by the change itself, for example a `<Show when={x()}>` where `x` is the held write: it appears with that change's commit, with no fallback. While the fallback shows, `isPending()` of the boundary is `false`, as for any first load.

#### `Loading` `on` prop: dependencies that show the fallback again

By default, once a `Loading` boundary has rendered content, it keeps that content visible during revalidation: like every reader of a pending value, it holds the write that made it pending until the data lands. The `on` prop is a **dependency list**: a tracked expression whose value is irrelevant — what matters is what it reads. Whenever anything it reads changes, the boundary stops waiting on its current content: if something under it is pending, it shows its fallback again until the new content is ready; if nothing is pending, nothing happens.
Expand Down Expand Up @@ -81,6 +83,8 @@ The shell keeps showing A until `product(2)` lands; the spinner arrives with B,

One shape shows no fallback at all: when the data the boundary is waiting on is also read outside it (a sibling `<Loading>` over the same `comments(id)`, an `isPending` on it in the header), or the write's `action` stays open until the data lands. The frame waits on that read, so by the time it commits the content is ready and the fallback was never needed. The first of these is structural — no ordering of the flights can show that fallback — and in development the `LOADING_ON_OUTSIDE_HOLD` diagnostic names the source; the fix is to move the outside read under the boundary so one hold owns the data. The second is a race the fallback may still win (an action that ends first shows it with the commit), and is not reported: during an `action`, show the wait with `isPending()` or an optimistic value, which is what a hold's stale content is for. The old content is on screen and valid the whole time; a `Loading` fallback says it is not.

_Note (2026-10-06):_ how a re-armed boundary treats data an earlier change still holds is deferred to a separate change; until then a re-arm behaves as described here.

It is possible to show the fallback beside the still-held frame anyway: a display-ahead read in `on` — `latest(id)`, `isPending()`, an optimistic signal — says the change is already on screen, so the fallback lands there too:

```
Expand Down
96 changes: 48 additions & 48 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t
- **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 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`.

**Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed:

- **Same tick:** mounts created outside a flush in one tick — two mounts over the held value, or a mount and a sync reader — share the tick's frame. The frame joins the hold, and the hold waits on the mounts' first loads.
Expand Down
30 changes: 15 additions & 15 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function redraw(b: Boundary): void {
/** GlobalQueue._catch: status from a frame reader, nearest boundary first.
* A loading boundary on the way records a pending reader whether or not it
* catches it (its `on` may collect it later). */
function catchStatus(node: Computed<any>, flags: number, error: unknown): boolean {
function catchStatus(node: Computed<any>, flags: number, error?: unknown): boolean {
if (flags === 0) {
// A status cleared. Judged by the node's status now, against each
// boundary's own rule (`unsettled`) — the error path clears pending
Expand Down Expand Up @@ -596,25 +596,23 @@ function createBoundary<T>(
}
if (isCollecting(b)) {
// A29's boundary exemption (#3540): a boundary MOUNTED over a held
// value (its first pass; the tree born held) shows its fallback now
// and the content at the commit — entering the transaction would
// make the output itself born held, and nothing would show until
// the commit. (The seam keeps a held reader until it is committed.)
// A boundary with a committed value reads a held tree and enters:
// the outside sees its committed value until the landing, which
// reveals the content — a fallback staged earlier is replaced ahead
// of the commit and never shown.
// value (its first pass; a first pass under it that read a hold was
// collected, `joinPass` — the tree's own, or a render effect's that
// binds the content) shows its fallback now and the content at the
// commit — entering the transaction would make the output itself
// born held, and nothing would show until the commit. The seam
// keeps a held reader until it is committed, then re-derives this
// pass. A boundary with a committed value reads a held tree and
// enters: the outside sees its committed value until the landing,
// which reveals the content — a fallback staged earlier is replaced
// ahead of the commit and never shown.
const self = getOwner() as Computed<any>;
if (
tree._config & CONFIG_HELD &&
b._readers.size !== 0 &&
self._statusFlags & STATUS_UNINITIALIZED &&
!(self._config & CONFIG_HELD)
) {
// The seam re-derives this pass once; by then the fallback is
// the committed value, and the next pass enters.
collecting.add(b);
)
return fallback(b);
}
// Readers under it still unready: the fallback, the tree untouched.
// The seam re-derives this pass when they settle.
if (prune(b, true) !== 0) return fallback(b);
Expand Down Expand Up @@ -776,6 +774,8 @@ export function createErrorBoundary<T, U>(
// Installed at module evaluation — present exactly when something imports a
// boundary. An app without one pays the three null checks and nothing else.
GlobalQueue._catch = catchStatus;
GlobalQueue._fresh = node =>
node._statusFlags & STATUS_UNINITIALIZED && catchStatus(node, STATUS_PENDING);
GlobalQueue._hidden = hidden;
GlobalQueue._boundarySeam = boundarySeam;
GlobalQueue._heldRun = heldRun;
20 changes: 13 additions & 7 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,10 +943,18 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension {
* reader is a render effect. A render effect is the frame, not a derivation
* (rule 3): in that transaction's own flush, or born into it (uninitialized,
* A29), it reads the staged value and holds nothing of its own; otherwise it
* reads the committed value instead (`frameRead`). */
* reads the committed value instead (`frameRead`). A node born into the
* future has no committed value: every reader joins it. A first pass is
* something not ready under a loading boundary that has not shown content:
* the boundary collects it and shows its fallback, and the pass is the
* boundary's, not the tick's (A29's boundary exemption, #3540). */
function joinPass(c: Computed<any>, el: Signal<any> | Computed<any>): void {
c._flags |= REACTIVE_JOINED;
if ((c as any)._type !== EFFECT_RENDER) joinPassTx(txOf(el));
if (
(el as Computed<any>)._statusFlags & STATUS_UNINITIALIZED ||
(c as any)._type !== EFFECT_RENDER
)
joinPassTx(txOf(el), GlobalQueue._fresh?.(c));
}

/** A15's stale reader (shared-hole and reveal corollaries): a render effect
Expand Down Expand Up @@ -1653,11 +1661,9 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
!(el._config & CONFIG_OVERRIDE) &&
!(c._config & CONFIG_CHILDREN_FORBIDDEN)
) {
if (owner._statusFlags & STATUS_UNINITIALIZED) {
(c as Computed<any>)._flags |= REACTIVE_JOINED;
joinPassTx(txOf(el));
} else if (frameRead(c as Computed<any>, el)) committed = true;
else joinPass(c as Computed<any>, el);
if (owner._statusFlags & STATUS_UNINITIALIZED || !frameRead(c as Computed<any>, el))
joinPass(c as Computed<any>, el);
else committed = true;
}
}
// Lanes: a lane's node (after the pull — the node is current). NOT_PENDING
Expand Down
23 changes: 15 additions & 8 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,15 @@ export let flushTransaction: Transaction | null = null;
* into it, and nothing else in the tick is — a write made after the mount
* is a mainline write, a render effect mounted beside it a stale reader.
* Inside a flush, or in a tick that already has its transaction (an
* action's body), the frame joins instead (`flushTransaction`). Cleared by
* the flush the join schedules. */
* action's body), the frame joins instead (`flushTransaction`) — except a
* first pass a loading boundary that has not shown content catches (`own`,
* A29's boundary exemption, #3540): it is the boundary's, not the tick's,
* and a flush that has joined nothing keeps it pass-scoped as outside one.
* Cleared by the flush the join schedules, and at the end of a flush that
* set it. */
export let passTx: Transaction | null = null;
export function joinPassTx(t: Transaction): void {
if (globalQueue._running || flushTransaction !== null) return joinFuture(t);
export function joinPassTx(t: Transaction, own?: unknown): void {
if ((globalQueue._running && !own) || flushTransaction !== null) return joinFuture(t);
if (passTx === null) passTx = resolveTx(t);
else merge(resolveTx(t), passTx);
schedule();
Expand Down Expand Up @@ -658,13 +662,15 @@ export class GlobalQueue implements IQueue {
// Boundaries (boundaries.ts): the display consumers between an observer
// and the root. `_catch` — status from a frame reader, nearest boundary
// first (true: caught, the root never hears of it; a clear — flags 0 —
// settles the reader there); `_hidden` — a frame reader behind a fallback
// is not on screen and holds nothing; `_boundarySeam` — the seam's sweep
// (readers gone or settled without a pass reveal; an `on` re-arm
// resolves).
// settles the reader there); `_fresh` — a pass that read a hold (true: a
// first pass a loading boundary that has not shown content caught,
// `joinPass`); `_hidden` — a frame reader behind a fallback is not on
// screen and holds nothing; `_boundarySeam` — the seam's sweep (readers
// gone or settled without a pass reveal; an `on` re-arm resolves).
declare static _catch:
| ((node: Computed<any>, flags: number, error: unknown) => boolean)
| undefined;
declare static _fresh: ((node: Computed<any>) => unknown) | undefined;
declare static _hidden: ((r: Computed<any>) => boolean) | undefined;
declare static _boundarySeam: (() => void) | undefined;
// `_heldRun` — a queued run under a fallback-showing boundary waits for
Expand Down Expand Up @@ -710,6 +716,7 @@ export class GlobalQueue implements IQueue {
if (__DEV__) DEV.hooks.onUpdate?.();
} finally {
this._running = false;
passTx = null;
}
}
/** L2 — the seam: end of the pure phase. Commit this flush's staged nodes,
Expand Down
Loading
Loading