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/loading-on-errorboundary-sibling-4638.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A `<Loading on>` re-arm whose content reads its async through an error boundary built outside the `<Loading>` shows its fallback with the change's frame again, beside a sibling re-armed boundary (#4638). A pending error-boundary output read only behind fallbacks no longer holds the frame.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:337` | async.ts×1 core.ts×2 | derived-write-then-derivation-3733.test.ts×5 equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). |
| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:345` | — | derived-write-then-derivation-3733.test.ts×1 equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. |
| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:353` | — | derived-write-then-derivation-3733.test.ts×1 errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". |
| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:361` | boundaries.ts×3 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. |
| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:361` | boundaries.ts×4 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. |
| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | seat.ts×1 truth.ts×1 verdict.ts×1 | fuzz-findings-l2.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… |
| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:175` | — | create-deferred-3710.test.ts×1 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:183` | async.ts×1 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… |
Expand Down
10 changes: 9 additions & 1 deletion packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,15 @@ function armedOver(node: Computed<any>): boolean | undefined {
function hiddenBy(r: Computed<any>): Boundary | undefined {
for (let b: Boundary | null | undefined = boundaryOf(r); b; b = b._parent)
if (b._fallback) return b;
return undefined;
// A boundary output passing a pending by (CONFIG_REDERIVE: an error
// boundary, A6) is transparent: on screen only through its readers. Read
// only behind fallbacks — an error boundary built outside the `<Loading>`
// whose content reads it — it holds nothing (F-4, D-14, #4638).
let h: Boundary | undefined,
s = r._subs;
if (r._config & CONFIG_REDERIVE && !(r as any)._type)
while (s !== null && (h = hiddenBy(s._sub))) s = s._nextSub;
return h;
}

/** `heldRunHook` (scheduler.ts): a queued run behind a fallback waits for the reveal
Expand Down
141 changes: 141 additions & 0 deletions packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* #4638 — two sibling `<Loading on={sel()}>` boundaries; one reads its async
* memo through an error boundary created in the component body (outside the
* Loading, as a value-level catch), the other reads a plain async memo keyed
* by the same signal. After the key changes, both re-arm and both fallbacks
* show now (F-4, F-5, D-14, D-15): the error boundary is a derivation, not a
* render effect, and its only reader is under the first Loading, so nothing
* outside either boundary reads the flights (D-16 does not apply).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
createErrorBoundary,
createLoadingBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
untrack
} from "../src/index.js";

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

/** A DOM-like part: a render effect under the owner writes its text. */
function part(read: () => string) {
const el = { text: "-" };
createRenderEffect(read, v => {
el.text = v;
});
return el;
}

function page(caught: boolean, sibling: boolean, header = false) {
const asks: { key: string; res: (v: any) => void }[] = [];
const ask = (key: string) => new Promise<any>(res => asks.push({ key, res }));
const view: {
header: string;
detail: { text: string } | null;
comments: { text: string } | null;
} = { header: "-", detail: null, comments: null };
const [sel, setSel] = createSignal(3);
let dispose!: () => void;
createRoot(d => {
dispose = d;
const detail = createMemo(() => ask(`detail:${sel()}`), { name: "detail" });
const shown = caught
? untrack(() =>
createErrorBoundary(
() => detail(),
() => null
)
)
: detail;
if (header)
createRenderEffect(
() => (shown() as any)?.title,
v => {
view.header = String(v);
}
);
const comments = createMemo(() => ask(`comments:${sel()}`), { name: "comments" });
const a = untrack(() =>
createLoadingBoundary(
() => part(() => (shown() as any)?.title),
() => part(() => `loading #${sel()}`),
{ on: () => sel() }
)
);
createRenderEffect(a, v => {
view.detail = v;
});
if (sibling) {
const b = untrack(() =>
createLoadingBoundary(
() => part(() => `comments ${(comments() as any[]).length}`),
() => part(() => "loading comments"),
{ on: () => sel() }
)
);
createRenderEffect(b, v => {
view.comments = v;
});
}
});
const settle = async (key: string, v: any) => {
const i = asks.findIndex(a => a.key === key);
if (i >= 0) asks.splice(i, 1)[0].res(v);
for (let k = 0; k < 4; k++) {
await vi.advanceTimersByTimeAsync(0);
flush();
}
};
const t = (el: { text: string } | null) => (el ? el.text : "-");
const screen = () =>
(header ? `${view.header} / ` : "") +
(sibling ? `${t(view.detail)} | ${t(view.comments)}` : t(view.detail));
return { setSel, settle, screen, dispose };
}

describe("#4638 sibling Loading on through an error boundary", () => {
for (const caught of [false, true])
for (const sibling of [false, true])
test(`${caught ? "error boundary" : "plain"}${sibling ? " + sibling" : ""}: both fallbacks show now`, async () => {
const p = page(caught, sibling);
flush();
await p.settle("detail:3", { title: "Crash" });
await p.settle("comments:3", [1]);
expect(p.screen()).toBe(sibling ? "Crash | comments 1" : "Crash");
p.setSel(1);
flush();
await p.settle("", null);
expect(p.screen()).toBe(sibling ? "loading #1 | loading comments" : "loading #1");
await p.settle("detail:1", { title: "One" });
expect(p.screen()).toBe(sibling ? "One | loading comments" : "One");
await p.settle("comments:1", [1, 2]);
expect(p.screen()).toBe(sibling ? "One | comments 2" : "One");
p.dispose();
});

// D-16: the error boundary also read by a render effect outside the
// Loading (a header) is an outside reader of the flight the change
// started — the frame waits on it, and the re-arms' fallbacks are never
// seen; the frame lands with `detail`.
test("error boundary also read outside (D-16): the frame holds until detail lands", async () => {
const p = page(true, true, true);
flush();
await p.settle("detail:3", { title: "Crash" });
await p.settle("comments:3", [1]);
expect(p.screen()).toBe("Crash / Crash | comments 1");
p.setSel(1);
flush();
await p.settle("", null);
expect(p.screen()).toBe("Crash / Crash | comments 1");
await p.settle("detail:1", { title: "One" });
expect(p.screen()).toBe("One / One | loading comments");
await p.settle("comments:1", [1, 2]);
expect(p.screen()).toBe("One / One | comments 2");
p.dispose();
});
});
78 changes: 78 additions & 0 deletions packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*
* #4638 (F-4, F-5, D-14, D-15): two sibling `<Loading on={sel()}>`
* boundaries. One reads its async memo through an error boundary built in the
* component body (a value-level catch, `createErrorBoundary`), the other a
* plain async memo keyed by the same signal. A `sel` write re-arms both: the
* error boundary is not a render effect and its only reader is under the
* first Loading, so nothing outside either boundary reads the new flights
* (D-16 does not apply) and both fallbacks show with the change's frame.
* The signals-only twin: `packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts`.
*/
import { expect, test } from "vitest";
import { Loading, createMemo, createSignal, flush, untrack } from "solid-js";
import { createErrorBoundary } from "solid-js/internal";
import { render } from "../src/index.js";

const tick = async () => {
for (let i = 0; i < 4; i++) {
await new Promise(r => setTimeout(r, 0));
flush();
}
};

for (const caught of [true, false])
test(`switch key (${caught ? "error boundary" : "plain"}): both fallbacks show now`, async () => {
const asks: { key: string; res: (v: any) => void }[] = [];
const ask = (key: string) => new Promise<any>(res => asks.push({ key, res }));
const settle = async (key: string, v: any) => {
asks
.splice(
asks.findIndex(a => a.key === key),
1
)[0]
.res(v);
await tick();
};
const c = document.body.appendChild(document.createElement("div"));
let select!: (n: number) => void;
const dispose = render(() => {
const [sel, setSel] = createSignal(3);
select = setSel;
const detail = createMemo(() => ask(`detail:${sel()}`));
const shown = caught
? untrack(() =>
createErrorBoundary(
() => detail(),
() => null
)
)
: detail;
const comments = createMemo(() => ask(`comments:${sel()}`));
return (
<>
<Loading on={sel()} fallback={<i>loading #{sel()}</i>}>
<b>{(shown() as any)?.title}</b>
</Loading>
<Loading on={sel()} fallback={<i> loading comments</i>}>
<u> comments {(comments() as any[]).length}</u>
</Loading>
</>
);
}, c);
await tick();
await settle("detail:3", { title: "Crash" });
await settle("comments:3", [1]);
expect(c.textContent).toBe("Crash comments 1");
select(1);
await tick();
expect(c.textContent).toBe("loading #1 loading comments");
await settle("detail:1", { title: "One" });
expect(c.textContent).toBe("One loading comments");
await settle("comments:1", [1, 2]);
expect(c.textContent).toBe("One comments 2");
dispose();
c.remove();
});
Loading