Skip to content
Open
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/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.
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`).

**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks its nearest loading boundary (`GlobalQueue._catch`, with `STATUS_UNINITIALIZED` in its flags; no new hook). Only a boundary that has not shown content catches it; a boundary that has (re-armed by `on` or not) neither catches nor records it, and no boundary past the nearest is asked, so which boundaries catch what is otherwise unchanged. When the boundary 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. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: 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; on a hold, from mainline: the same three content shapes; under an inner Loading that has not shown content, the outer revealed one records nothing, is not pending, and the commit does not wait for the first load; directly under a revealed Loading, or under a revealed one inside one that has not shown content, not caught, as before; `on` boundaries revealed (re-armed or not) as before, not yet revealed as a Loading without `on`); `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
43 changes: 27 additions & 16 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,14 @@ 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 {
* catches it (its `on` may collect it later). A pass that read a hold
* (`joinPass`: STATUS_UNINITIALIZED in `flags`) is asked too. A first pass
* is not pending — it has never committed — and only A29's boundary
* exemption catches it (#3540): the nearest loading boundary, if it has not
* shown content. One that has, re-armed by `on` or not, neither catches nor
* records it (it joins the hold like any reader of committed content), no
* boundary past the nearest is asked, and a committed pass is not caught. */
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 All @@ -226,6 +232,13 @@ function catchStatus(node: Computed<any>, flags: number, error: unknown): boolea
}
for (let b = boundaryOf(node); b !== undefined; b = b._parent ?? undefined) {
if (!(b._type & flags)) continue;
// A pass that read a hold: the nearest loading boundary only, and only
// a first pass before it has shown content (then it is collecting).
if (
flags & STATUS_UNINITIALIZED &&
(b._initialized || !(node._statusFlags & STATUS_UNINITIALIZED))
)
break;
if (isCollecting(b)) {
caught(b, node, error);
return true;
Expand Down Expand Up @@ -596,25 +609,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
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._catch?.(c, STATUS_PENDING | STATUS_UNINITIALIZED));
}

/** 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
25 changes: 16 additions & 9 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,12 +662,14 @@ 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; a pass that read a hold — STATUS_UNINITIALIZED
// in flags, `joinPass` — is caught only as a first pass, by the nearest
// loading boundary if it has not shown content); `_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)
| ((node: Computed<any>, flags: number, error?: unknown) => boolean)
| undefined;
declare static _hidden: ((r: Computed<any>) => boolean) | undefined;
declare static _boundarySeam: (() => void) | undefined;
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