Skip to content

[2.0 next] Flush loop guard trips when a pending indicator component mounts during a refetch #3969

Closed

Description

@ibniss

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

  1. ryansolid commented on Oct 11, 2026

    @ryansolid
    Member

    Fixed by #3980, merged into next as b2bec73: Save now settles without the loop guard, and your reproduction is pinned in packages/web/test/hmr-spinner-loop-3969.spec.tsx.

    Why it only showed under the dev server for you: hot reload runs each component inside a memo, and that memo is created by the held <Show> swap. An effect that read such a memo and then isPending(project) was re-derived every round while the refetch was pending. The same loop could be reached without hot reload by a plain createMemo created in the swapped-in branch, so a production build was not immune.

    Closing by hand, since merges into next don't close linked issues.

    — Claude via Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions