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

A `<Loading on>` boundary no longer shows its fallback when a held `on` change commits under an action's own `refresh()` (#4379). When an `on` change is held with an action (an input changed under its optimistic guess, #4336) and the content's new answer lands inside that hold a flush after the change, the boundary now folds the staged fallback back, as it already did when the content was ready at the change. The action's commit shows the content, and the quiet `refresh()` that follows keeps it on screen until it answers.
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). 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).
**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). A fallback staged into the hold its readers land in folds back at any seam, not only the re-arm's own: readers still pending at the re-arm land into that hold a flush later, and the content is ready when it commits (#4379, `tests/loading-on-held-filter-refresh-4379.test.ts`).

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

Expand Down
6 changes: 5 additions & 1 deletion packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,12 @@ function boundarySeam(): void {
if (flags & REACTIVE_ZOMBIE) continue;
prune(b, false);
const o = b._output!;
// A fallback staged into the very hold its readers landed in folds back
// (D-16, U-9): the content is ready when that hold commits (D-15). Not
// only at the re-arm's own seam: readers pending then land, held, a
// flush later (#4379 — the staged fallback otherwise commits with the
// hold, and a quiet refresh then caught keeps it up, F-9).
if (
b._armed &&
!b._initialized &&
o._config & CONFIG_HELD &&
[...b._readers].every(
Expand Down
207 changes: 207 additions & 0 deletions packages/signals/tests/loading-on-held-filter-refresh-4379.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* #4379 (ReactiveScript tracker eval): a keyed `createOptimisticStore` list
* whose question reads `filter()`, under `<Loading on={filter()}>`, with a
* render effect per row (as `<For>` binds each row's text). An action
* guesses a row, yields, then `refresh()`es the list. A plain `filter`
* write while the action is out is held with it (D-34, #4336), so the
* boundary's swap is staged into that hold (D-15) and the screen keeps the
* old list.
*
* Once the new question's answer lands, held with the same action, the
* content is ready inside the hold the fallback was staged into: the seam
* folds the fallback back (D-16, U-9), as it does when the readers are
* already landed at the re-arm. When the action ends, its own `refresh()`
* is quiet (F-9): it must not pull the fallback on screen. On `next` the
* fold only ran in the re-arm's own flush (`_armed`); the rows' effects
* survive the answer (same keys) and land a flush later, so the staged
* fallback committed with the action, and the refresh's pending, caught by
* a boundary still collecting, showed it until the refresh answered.
*/
import { describe, expect, it } from "vitest";
import {
action,
createLoadingBoundary,
createOptimisticStore,
createRenderEffect,
createRoot,
createSignal,
flush,
mapArray,
refresh,
untrack
} from "../src/index.js";

const tick = async () => {
for (let i = 0; i < 4; i++) {
await new Promise(r => setTimeout(r, 0));
flush();
}
};
type Issue = { id: number; assignee: string | null };
const OPEN: Issue[] = [
{ id: 1, assignee: null },
{ id: 3, assignee: null }
];
const ASSIGNED: Issue[] = [
{ id: 1, assignee: null },
{ id: 3, assignee: "ann" }
];

function page(on: boolean) {
const asks: { key: string; res: (v: any) => void }[] = [];
const ask = (key: string) => new Promise<any>(res => asks.push({ key, res }));
const land = async (key: string, v: any) => {
const i = asks.findIndex(a => a.key === key);
if (i < 0) throw new Error(`not asked: ${key}; asked: ${asks.map(a => a.key)}`);
// A fresh answer each time, as a fetch gives.
asks.splice(i, 1)[0].res(structuredClone(v));
await tick();
};
const frames: string[] = [];
let setFilter!: (f: string) => void;
let assign!: (id: number, who: string) => void;
createRoot(() => {
const [filter, sf] = createSignal("open");
setFilter = sf;
const [issues, setIssues] = createOptimisticStore<Issue[]>(
async (d: Issue[]) => {
const r: Issue[] = await ask(`fetchIssues:${filter()}`);
d.length = r.length;
r.forEach((x, i) => ((d as any)[i] = x));
},
[],
{ key: "id" } as any
);
assign = action(function* (id: number, who: string) {
const i = issues.findIndex(t => t.id === id);
setIssues((d: Issue[]) => {
d[i].assignee = who;
});
yield ask(`assign:${id},${who}`);
refresh(issues);
}) as any;
const list = untrack(() =>
createLoadingBoundary(
() => {
const m = mapArray(
() => issues,
(i: any) => {
// Frame readers per row, under the boundary (`<For>`'s text
// holes). The `id` read is not served by the guess: over the
// new question it is an ordinary pending read (#4336
// `stands`), recorded by the boundary, and it lands held.
createRenderEffect(
() => i.id,
() => {}
);
createRenderEffect(
() => i.assignee,
() => {}
);
return () => `#${i.id}(${i.assignee ?? "-"})`;
}
);
return () =>
m()
.map(f => f())
.join(" ");
},
() => "loading",
on ? { on: () => filter() } : undefined
)
);
createRenderEffect(
() => {
const v: any = list();
return typeof v === "function" ? v() : v;
},
v => {
if (frames.at(-1) !== v) frames.push(v);
}
);
});
return {
land,
asks,
frames,
screen: () => frames.at(-1),
setFilter: async (f: string) => {
setFilter(f);
flush();
await tick();
},
assign: async (id: number, who: string) => {
assign(id, who);
flush();
await tick();
}
};
}

const MOUNT = ["loading", "#1(-) #3(-)"];

describe("#4379 a held `on` change whose content lands inside the hold", () => {
for (const on of [true, false]) {
it(`toggled back while held: the action's refresh shows no fallback (on=${on})`, async () => {
const p = page(on);
await tick();
await p.land("fetchIssues:open", OPEN);
await p.assign(3, "ann");
await p.setFilter("closed");
await p.setFilter("open");
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("fetchIssues:closed", []);
await p.land("fetchIssues:open", OPEN);
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("assign:3,ann", undefined);
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("fetchIssues:open", ASSIGNED);
expect(p.frames).toEqual([...MOUNT, "#1(-) #3(ann)"]);
expect(p.asks).toEqual([]);
});

// The same seam without the toggle: the new question's answer keeps the
// rows (same keys), so their effects survive and land held.
it(`a new question whose answer keeps the rows: no fallback (on=${on})`, async () => {
const p = page(on);
await tick();
await p.land("fetchIssues:open", OPEN);
await p.assign(3, "ann");
await p.setFilter("closed");
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("fetchIssues:closed", OPEN);
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("assign:3,ann", undefined);
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("fetchIssues:closed", ASSIGNED);
expect(p.frames).toEqual([...MOUNT, "#1(-) #3(ann)"]);
expect(p.asks).toEqual([]);
});
}

// Control (passes on `next`): the answer drops the rows, so their effects
// die and the boundary's own pass finds nothing left to wait on.
it("control: an answer that drops the rows holds the old list to the refresh", async () => {
const p = page(true);
await tick();
await p.land("fetchIssues:open", OPEN);
await p.assign(3, "ann");
await p.setFilter("closed");
await p.land("fetchIssues:closed", []);
await p.land("assign:3,ann", undefined);
expect(p.screen()).toBe("#1(-) #3(ann)");
await p.land("fetchIssues:closed", []);
expect(p.frames).toEqual([...MOUNT, "#1(-) #3(ann)", ""]);
});

// Control: nothing holds the change — the fallback shows with it (D-15).
it("control: no hold — the fallback shows with the change and clears at its answer", async () => {
const p = page(true);
await tick();
await p.land("fetchIssues:open", OPEN);
await p.setFilter("closed");
expect(p.screen()).toBe("loading");
await p.land("fetchIssues:closed", OPEN);
expect(p.frames).toEqual([...MOUNT, "loading", "#1(-) #3(-)"]);
});
});
127 changes: 127 additions & 0 deletions packages/web/test/loading-on-held-filter-refresh-4379.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/
// #4379 — an action guesses a row of a keyed optimistic store, yields, then
// `refresh()`es it. A `filter` change made meanwhile (closed, then back to
// open) holds with the action (D-34). When the action ends, the held change
// commits while the action's own refresh is still in flight; a refresh is
// quiet (F-9), so `<Loading on={filter()}>` keeps the list up, as it does
// without `on` and for the closed-only change. The signals-side pin is
// `packages/signals/tests/loading-on-held-filter-refresh-4379.test.ts`.
import { expect, test } from "vitest";
import {
Loading,
Errored,
For,
action,
createOptimisticStore,
createSignal,
flush,
refresh
} from "solid-js";
import { render } from "../src/index.js";

const tick = async () => {
for (let i = 0; i < 4; i++) {
await new Promise(r => setTimeout(r, 0));
flush();
}
};
type Issue = { id: number; assignee: string | null };

for (const useOn of [true, false])
for (const toggle of [true, false])
test(`on=${useOn} toggle=${toggle}: the action's refresh never shows the fallback`, async () => {
const asks: { key: string; res: (v: any) => void }[] = [];
const ask = (key: string) => new Promise<any>(res => asks.push({ key, res }));
const land = async (key: string, v: any) => {
const i = asks.findIndex(a => a.key === key);
if (i < 0) throw new Error(`not asked ${key}: ${asks.map(a => a.key)}`);
asks.splice(i, 1)[0].res(v);
await tick();
};
const container = document.body.appendChild(document.createElement("div"));
const frames: string[] = [];
const snap = () => frames.push(container.textContent!);
let setFilter!: (f: string) => void;
let assign!: (id: number, who: string) => void;
const dispose = render(() => {
const [filter, sf] = createSignal("open");
setFilter = sf;
const [issues, setIssues] = createOptimisticStore<Issue[]>(
async (d: Issue[]) => {
const r: Issue[] = await ask(`fetchIssues:${filter()}`);
d.length = r.length;
r.forEach((x, i) => ((d as any)[i] = x));
},
[],
{ key: "id" } as any
);
assign = action(function* (id: number, who: string) {
try {
const i = issues.findIndex(t => t.id === id);
setIssues((d: Issue[]) => {
d[i].assignee = who;
});
yield ask(`assign:${id},${who}`);
refresh(issues);
} catch {}
});
return (
<Errored fallback={(e: any) => <p>err {String(e()?.message ?? e())}</p>}>
<Loading on={useOn ? filter() : undefined} fallback={<p>loading {filter()}</p>}>
<For each={issues}>
{(it: any) => (
<i>
#{it.id}({it.assignee ?? "-"})
</i>
)}
</For>
</Loading>
</Errored>
);
}, container);
await tick();
await land("fetchIssues:open", [
{ id: 1, assignee: null },
{ id: 3, assignee: null }
]);
snap();
assign(3, "ann");
flush();
await tick();
snap();
setFilter("closed");
flush();
await tick();
if (toggle) {
setFilter("open");
flush();
await tick();
}
snap();
await land("fetchIssues:closed", []);
if (toggle)
await land("fetchIssues:open", [
{ id: 1, assignee: null },
{ id: 3, assignee: null }
]);
snap();
await land("assign:3,ann", undefined);
snap();
await land(
`fetchIssues:${toggle ? "open" : "closed"}`,
toggle
? [
{ id: 1, assignee: null },
{ id: 3, assignee: "ann" }
]
: []
);
snap();
dispose();
container.remove();
const held = "#1(-)#3(ann)";
expect(frames).toEqual(["#1(-)#3(-)", held, held, held, held, toggle ? "#1(-)#3(ann)" : ""]);
});
Loading