Repository navigation
[2.0 next] Flush loop guard trips when a pending indicator component mounts during a refetch #3969
Description
Summary
Two plain signal writes from a click handler make flush hit the dev loop guard twice:
Potential Infinite Loop Detected. Kept alive by scheduled work; last staged node: value
One write refetches every async memo. The other switches a Show to a fallback that renders <Spinner on={isPending(project)} />. The error is thrown inside flush and swallowed: nothing reaches the console, and the page ends in the right state. A production build has no guard and would run the same loop. In the app this came from (more rows and effects), it blocked the page for seconds per save.
Reproduces on next at a497d4b (which includes the #3945 fix), client rendering, Chrome. We found it through @solidjs/router 2.0.0-next.38, where an action revalidates its queries and then clears a selection. Neither the router nor an action is needed.
Reproduction
import { createMemo, createSignal, isPending, Loading, Show } from "solid-js";
import { render } from "@solidjs/web";
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
// A refetch-everything signal, standing in for a router query cache's revalidation.
const [refresh, setRefresh] = createSignal(0);
const fetchAfter = <T,>(value: T) => (refresh(), wait(20).then(() => value));
const Spinner = (props: { on: boolean }) => (
<Show when={props.on}>
<span>updating</span>
</Show>
);
function App() {
const project = createMemo(() => fetchAfter({ id: "a" }));
const rows = createMemo(() => fetchAfter([{ id: project().id }]));
const tags = createMemo(() => fetchAfter(["A", "B"]));
const [selected, setSelected] = createSignal(false);
const save = () => {
setRefresh(n => n + 1);
setSelected(false);
};
return (
<Loading fallback={<p>loading</p>}>
<button onClick={() => setSelected(true)}>Select</button>
<button onClick={save}>Save</button>
<div>
<Show when={tags().length > 0 && selected()} fallback={<Spinner on={isPending(project)} />}>
<span>selected</span>
</Show>
<p>{rows()[0]!.id}</p>
</div>
</Loading>
);
}
render(() => <App />, document.getElementById("root")!);The guard's error never reaches the console, so record it first:
window.loops = []; const E = window.Error;
const W = function (m, o) { if (String(m).includes("Infinite Loop")) window.loops.push(String(m)); return new E(m, o); };
W.prototype = E.prototype; Object.setPrototypeOf(W, E); window.Error = W;Then click Select, then Save. window.loops gets two entries per Save.
Expected: Save settles without hitting the guard. The spinner shows while the refetch is pending and hides when it lands.
What is required
Changing any one of these stops the loop:
| Ingredient | Change that stops the loop |
|---|---|
project refetches on refresh |
project fetched once |
rows derives from project and is rendered |
rows with a constant id, or not rendered |
The Show condition reads another refetched memo (tags) |
condition selected() only |
Spinner is a component |
the same Show inlined in the fallback |
The Show and the rows sit in a <div> |
a fragment, or no wrapper |
Not required: an action or transition, a router, a memo between project and isPending, or whether Spinner gets a value or an accessor. The last two table rows suggest scheduler ordering rather than app logic, since wrapping alone turns the loop on and off.
It does not reproduce under jsdom: the same component as a vitest spec in packages/web passes.
Possibly related: #3956 (same message, isPending plus a refetched list, no minimal repro), #3945, and the mount-under-transition staging work (#3540, #3882, #3843).
Activity
test(web): fix two type errors in the #3969 spec
fix(signals): a pass joined only through born-held nodes is not re-de…
ryansolid commentedon Oct 11, 2026
Generated by Claude Code