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/rearm-boundary-scope-u9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A Loading boundary re-armed by `on` owns the content under it that reads a hold another change made (D-14, D-16, U-9). Before, that content joined the hold, so no fallback showed and the boundary reported pending, and a held tree under a re-armed boundary could redraw forever. Now the fallback shows with the re-arming change and the content reveals at the hold's commit, which no longer waits for it. A re-arm whose own change joins that hold still shows no fallback (the frame waits on the same data), and `LOADING_ON_OUTSIDE_HOLD` is reported only for a flight the change itself started.
22 changes: 11 additions & 11 deletions packages/signals/docs/OPEN-QUESTIONS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Whether a fallback is seen depends on whether the boundary is on screen. A fresh

A flight whose only observers are render effects behind a fallback holds no change — a memo under the boundary that no render effect reads was never an observer at all, and one rendered by an effect outside the boundary is that effect's read (F-3). In both orders: a render effect created under a fallback never counts, and a reader that counted while the boundary showed content stops counting the moment `on` sends it back to the fallback. Writes the hold kept for those readers alone commit at once (`Boundary: Loading... | Sum: 2` publishes as one frame); a reader outside the boundary still holds (`Sum` waits until `details` lands). The wait does not vanish; it moves onto the boundary: after the reset the fallback stays until every reader under it has settled — including readers whose flights started before the reset — then one coherent reveal.

**Collapses:** [A33](./SPEC-ASYNC-SEMANTICS.md#a33-a-fallback-caught-flight-holds-no-transaction-a-loading-reset-moves-the-hold-onto-the-boundary) (#3375, #3459), §23.1 item 1, recovery 2.2. **Status:** standing. The nested form — an outer `on`-reset boundary not waiting for a flight its nested `on`-reset boundary catches — is built (U-4, 2026-10-13: a reader a nested collecting boundary holds is that boundary's alone; `tests/fuzz-findings-l2.test.ts`, fuzzer finding F1).
**Collapses:** [A33](./SPEC-ASYNC-SEMANTICS.md#a33-a-fallback-caught-flight-holds-no-transaction-a-loading-reset-moves-the-hold-onto-the-boundary) (#3375, #3459), §23.1 item 1, recovery 2.2. **Status:** standing. The nested form — an outer `on`-reset boundary not waiting for a flight its nested `on`-reset boundary catches — is built (U-4, 2026-10-13: a reader a nested collecting boundary holds is that boundary's alone; `tests/fuzz-findings-l2.test.ts`, fuzzer finding F1). The re-arm over a hold is built (U-9, 2026-10-14): content an earlier hold staged under the boundary, or that reads a hold after the re-arm, is the boundary's — the fallback shows now and the content reveals at that hold's commit, which does not wait for it; no redraw loop (`tests/loading-fallback-in-flush-3540.test.ts`, the re-armed shapes; `tests/loading-on-frame-following-3540.test.ts` §3b, §3c).

### D-15. `on` is a dependency list; the swap lands with the frame of the change that caused it

Expand Down
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… |
| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:181` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… |
| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… |
| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… |
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:197` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… |
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:205` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … |
| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:213` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… |
Expand Down
43 changes: 39 additions & 4 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import {
EFFECT_RENDER,
EFFECT_TRACKED,
EFFECT_USER,
NOT_PENDING,
REACTIVE_DISPOSED,
REACTIVE_LANE_READ,
REACTIVE_ZOMBIE,
Expand All @@ -64,17 +63,20 @@ import { link } from "./core/graph.js";
import { enqueueSub } from "./core/heap.js";
import { cleanup, createOwner, dispose, getOwner } from "./core/owner.js";
import {
clock,
flushTransaction,
GlobalQueue,
globalQueue,
haltReactivity,
heldUnder,
joinFuture,
passLane,
passTx,
resolveTx,
schedule,
setPassLane,
txOf,
unhold,
type Transaction
} from "./core/scheduler.js";
import { attrHooks } from "./core/attribution-hooks.js";
Expand Down Expand Up @@ -199,6 +201,8 @@ function caught(b: Boundary, node: Computed<any>, error: unknown): void {
function flip(b: Boundary): void {
b._initialized = false;
b._lane = b._armLane;
const o = b._output!;
if (!b._lane && o._config & CONFIG_HELD && txOf(o) !== flushTransaction) unhold(o);
if (b._lane !== null && b._output !== null)
GlobalQueue._laneStage!(b._output, b._lane, true, false);
}
Expand Down Expand Up @@ -249,6 +253,13 @@ function catchStatus(
return false;
}

/** The nearest loading boundary that would catch `node` was re-armed this
* flush. */
function armedOver(node: Computed<any>): boolean | undefined {
for (let b: Boundary | null | undefined = boundaryOf(node); b; b = b._parent)
if (b._type & STATUS_PENDING && isCollecting(b)) return b._armed;
}

/** The nearest boundary on `r`'s chain showing its fallback, if any
* (GlobalQueue._hidden: `r` is behind a fallback). */
function hiddenBy(r: Computed<any>): Boundary | undefined {
Expand Down Expand Up @@ -306,7 +317,10 @@ export function prune(b: Boundary, pass: boolean): number {
for (const r of b._readers) {
if (r._flags & REACTIVE_DISPOSED) b._readers.delete(r);
else if (!(r._statusFlags & mask)) {
if (r !== b._tree && (r._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) {
if (
(r !== b._tree || b._armed) &&
(r._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD
) {
// Landed, held: the content is that transaction's — the output's
// pass enters it and the reveal lands with the reader's run. The
// seam, past its park decision, waits for the commit instead. (A
Expand Down Expand Up @@ -351,6 +365,18 @@ function boundarySeam(): void {
// A parked frame's boundary (zombie) keeps its state for its revival.
if (flags & REACTIVE_ZOMBIE) continue;
prune(b, false);
const o = b._output!;
if (
b._armed &&
!b._initialized &&
o._config & CONFIG_HELD &&
[...b._readers].every(
r => !(r._statusFlags & STATUS_PENDING) && r._config & CONFIG_HELD && txOf(r) === txOf(o)
)
) {
b._readers.clear();
b._initialized = true;
}
if (__DEV__ && b._armed && !b._initialized && !b._ahead) reportOutsideHold(b);
b._armed = false;
if (b._readers.size === 0) {
Expand Down Expand Up @@ -387,6 +413,9 @@ function reportOutsideHold(b: Boundary): void {
const sources = r._x?._pendingSources;
if (sources === undefined) continue;
for (const source of sources) {
// Only the flight the change itself started (D-16): an outside reader
// of an earlier write's flight holds nothing the change waits on.
if (source._time !== clock) continue;
for (let s = source._subs; s !== null; s = s._nextSub) {
if (!outside(s._sub, true)) continue;
const name = (source as any)._name as string | undefined;
Expand Down Expand Up @@ -459,8 +488,13 @@ function arm(b: Boundary): void {
b._armLane = lane;
if (__DEV__) b._ahead = lane !== null;
collecting.add(b);
const n = b._readers.size;
heldUnder(b._owner, flushTransaction, r => record(b, r));
for (const r of b._readers) {
if (r._statusFlags & STATUS_PENDING && !(r._flags & REACTIVE_DISPOSED)) {
if (
b._readers.size !== n ||
(r._statusFlags & STATUS_PENDING && !(r._flags & REACTIVE_DISPOSED))
) {
// What it collects was forwarded through it to the boundaries above
// too (`catchStatus` records a reader in every boundary on the way).
// It is this boundary's alone now (B5, the nearest catcher): an outer
Expand Down Expand Up @@ -821,7 +855,8 @@ export function createErrorBoundary<T, U>(
// boundary. An app without one pays the three null checks and nothing else.
GlobalQueue._catch = catchStatus;
GlobalQueue._fresh = (node, held) =>
!held && node._statusFlags & STATUS_UNINITIALIZED && catchStatus(node, STATUS_PENDING);
!held &&
(node._statusFlags & STATUS_UNINITIALIZED ? catchStatus(node, STATUS_PENDING) : armedOver(node));
GlobalQueue._hidden = hiddenBy;
GlobalQueue._boundarySeam = boundarySeam;
GlobalQueue._heldRun = heldRun;
3 changes: 2 additions & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,8 @@ export function propagateStatus(
if (
passLane === null &&
(sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD &&
globalQueue._running
globalQueue._running &&
!GlobalQueue._fresh?.(sub, pendingSource._time === clock)
)
joinFuture(txOf(sub));
return;
Expand Down
3 changes: 2 additions & 1 deletion packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// a sync write (#3460). The pass is the lane's if it reads the lane's
// world (`read`), and has left it otherwise.
if (tx._lane) {
} else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx);
} else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT))
joinPassTx(tx, GlobalQueue._fresh?.(el, el._statusFlags & STATUS_UNINITIALIZED));
else if (
tx !== flushTransaction &&
!(el._statusFlags & STATUS_UNINITIALIZED || el._config & CONFIG_HELD_FRAME)
Expand Down
4 changes: 2 additions & 2 deletions packages/signals/src/core/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ function runEffect(node: Effect<any>, type: number): void {
// hidden subtree. The synchronous first render on creation is not a queued
// run and goes through. `_modified` stays set; the boundary re-queues the
// run (`release`).
if (GlobalQueue._heldRun && GlobalQueue._heldRun(node)) return;
if (GlobalQueue._heldRun?.(node)) return;
// Error arm (#2840), user effects only: a compute-phase error that is still
// the node's settled state at effect time runs the bundle's error handler in
// this same imperative, writable scope. Unwrap the StatusError used for
Expand Down Expand Up @@ -262,7 +262,7 @@ export function trackedEffect(fn: () => void | (() => void), options?: NodeOptio
// pass, but several passes in one flush each enqueue `_run` into the same
// user queue, and this gate is what collapses them into one run.
if (!node._modified || node._flags & REACTIVE_DISPOSED) return;
if (GlobalQueue._heldRun && GlobalQueue._heldRun(node)) return;
if (GlobalQueue._heldRun?.(node)) return;
if (__DEV__) setTrackedQueueCallback(true);
try {
node._modified = false;
Expand Down
37 changes: 25 additions & 12 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,7 @@ export function blocked(t: Transaction): boolean {
judge ??= t;
// An action still running in it holds it open (action.ts). Its lanes
// (lanes.ts): a lane under `t` blocks it while blocked itself.
const r =
t._open !== 0 ||
blockedBy(t._nodes, t) ||
(!!GlobalQueue._lanesBlocked && GlobalQueue._lanesBlocked(t));
const r = t._open !== 0 || blockedBy(t._nodes, t) || !!GlobalQueue._lanesBlocked?.(t);
judge = prev;
return r;
}
Expand Down Expand Up @@ -354,10 +351,7 @@ function blockedBy(nodes: Signal<any>[], owner: Transaction, own = false): boole
* everything under it, so a reader there is not on screen and holds
* nothing — boundaries.ts). */
function onScreen(r: Computed<any>, t: Transaction): boolean {
return (
!(r._flags & REACTIVE_ZOMBIE && removalStagedBy(r, t)) &&
!(GlobalQueue._hidden && GlobalQueue._hidden(r))
);
return !(r._flags & REACTIVE_ZOMBIE && removalStagedBy(r, t)) && !GlobalQueue._hidden?.(r);
}
/** A15 (#3463): a reader whose removal is staged — a zombie, parked by a
* pass awaiting its commit — is still on screen and live for every hold,
Expand All @@ -367,10 +361,9 @@ function onScreen(r: Computed<any>, t: Transaction): boolean {
* the nearest non-zombie ancestor. */
function removalStagedBy(r: Computed<any>, t: Transaction): boolean {
let o: Owner | null = r;
while (o !== null && (o as any)._flags & REACTIVE_ZOMBIE) o = o._parent;
while ((o as any)?._flags & REACTIVE_ZOMBIE) o = o!._parent;
return (
o !== null &&
(o._config & CONFIG_HELD) !== 0 &&
!!((o?._config as number) & CONFIG_HELD) &&
(o as any)._x?._transaction != null &&
txOf(o as any) === resolveTx(t)
);
Expand Down Expand Up @@ -437,6 +430,26 @@ function holdFrame(owner: Owner, t: Transaction): void {
}
}

/** The nodes under `owner` a transaction other than `t` holds (boundaries.ts:
* what an earlier hold staged under a re-armed boundary). */
export function heldUnder(
owner: Owner,
t: Transaction | null,
fn: (n: Computed<any>) => void
): void {
for (let c = owner._firstChild; c !== null; c = c._nextSibling) {
if ((c._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && txOf(c as any) !== t)
fn(c as Computed<any>);
heldUnder(c, t, fn);
}
}

/** `n` leaves the hold it was staged in: its next pass publishes mainline. */
export function unhold(n: Signal<any>): void {
n._x!._transaction = null;
n._config &= ~CONFIG_HELD;
}

export const dirtyQueue: Heap = {
_heap: new Array(2000).fill(undefined),
_marked: false,
Expand Down Expand Up @@ -956,7 +969,7 @@ export class GlobalQueue implements IQueue {
* first: a fallback catches what is not ready under it, and the root
* never hears of it. */
notify(node: Computed<any>, mask: number, flags: number, error?: any): boolean {
if (GlobalQueue._catch && GlobalQueue._catch(node, flags & mask, error)) return true;
if (GlobalQueue._catch?.(node, flags & mask, error)) return true;
if (mask & STATUS_PENDING) {
if (flags & STATUS_PENDING) {
if (__DEV__ && _enforceLoadingBoundary) _hitUnhandledAsync = true;
Expand Down
Loading
Loading