Skip to content

2.0.0-beta.15: isPending(() => latest(x)) in a user effect loops the scheduler — dev throws "Potential Infinite Loop Detected", prod spins forever #2843

Description

@yumemi-thomas

Describe the bug

A user effect watching the canonical stale-while-revalidate flag — createEffect(() => isPending(() => latest(asyncMemo)), …) — sends the scheduler into an infinite loop on the first source write after the async memo settles. One click on a refetch button is enough:

  1. In dev, flush()'s iteration guard fires after 100,000 spins: an uncaught Error: Potential Infinite Loop Detected. from the microtask-scheduled flush, after a visible stutter.
  2. In prod the guard is __DEV__-only, so the while (scheduled) loop in flush() spins forever — pegged CPU, permanently unresponsive app.
  3. As a side symptom, the pending flag never turns true at any point (the 2.0.0-beta.15: A few latest() bugs #2829/2.0.0-beta.15: isPending(() => latest(x)) is inconsistent across node shapes #2831 family's claim), so the indicator the effect exists for also never works.

The trigger conditions are narrow, which is why this hides in real apps (each verified by flipping it individually):

  • the read must be isPending(() => latest(m))isPending(m) directly does not loop;
  • it must be read in a user effect (createEffect) — the same expression in JSX (a render effect) does not loop;
  • no render effect may subscribe to the async memo anywhere — rendering latest(data) in any component masks the bug completely.

That masking is the practical danger: a data-layer service or pending-indicator effect works fine while some component happens to display the value, and turns into a CPU spin the moment nothing renders it — e.g. right after navigating away from the page that shows the data. Any post-settle write triggers it (event handler, timer, plain call); no manual flush() is involved.

Your Example Website or App

https://stackblitz.com/edit/solidjs-templates-brgewkut?file=src%2FApp.tsx

Bug reproduced = clicking refetch once produces Potential Infinite Loop Detected in the on-page error log (and the status never shows revalidating…). Fixed = status flips to revalidating…, back to idle ~300ms later, no errors. Validated against 2.0.0-beta.15.

The status line is written with a raw DOM write from the effect so it stays visible even when the scheduler wedges; data is deliberately not rendered anywhere (see the masking condition above):

import { createEffect, createMemo, createSignal, isPending, latest } from "solid-js";

const delay = (ms: number) => new Promise(r => setTimeout(r, ms));

export default function App() {
  const [version, refetch] = createSignal(0);
  let statusEl!: HTMLElement;
  let errLog!: HTMLPreElement;

  // an async source that refetches whenever `version` bumps
  const data = createMemo(async () => {
    const v = version();
    await delay(300);
    return `payload v${v}`;
  });

  // stale-while-revalidate indicator: latest() for the value identity,
  // isPending() for the in-flight flag.
  // NOTE: `data` is not rendered anywhere — a JSX read of latest(data)
  // masks the bug.
  createEffect(
    () => isPending(() => latest(data)),
    pending => {
      statusEl.textContent = pending ? "revalidating…" : "idle";
    }
  );

  window.addEventListener("error", e => {
    errLog.textContent += e.message + "\n";
  });

  return (
    <main style={{ "font-family": "system-ui", padding: "16px" }}>
      <h2>isPending(() =&gt; latest(x)) loops the scheduler on refetch</h2>
      <p>
        status: <b ref={statusEl}></b>
      </p>
      <button onClick={() => refetch(v => v + 1)}>refetch</button>
      <pre ref={errLog}>uncaught errors:{"\n"}</pre>
    </main>
  );
}

Steps to Reproduce the Bug or Issue

  1. Wait for the initial fetch to settle (~300ms): the status reads idle.
  2. Click refetch once. Actual — the page stutters while the scheduler spins 100,000 iterations, then the on-page log (and console) shows:
uncaught errors:
Error: Potential Infinite Loop Detected.

and the status still reads idle — it never showed revalidating… at any point.

In a production build the same click never reaches an error: the guard does not exist there, and the flush loop spins indefinitely.

Expected behavior

The status flips to revalidating…, returns to idle ~300ms later, and no error is thrown:

status: revalidating…   →   status: idle

Independently of how the pending-flag semantics shake out, a source write should never be able to put flush() into an unbounded spin. The dev-mode iteration guard is not sufficient mitigation here: the trigger is state-shaped, not code-shaped — the identical app passes development testing while any component renders the value, and first spins in production when a user navigates away from that view before a refetch.

Screenshots or Videos

No response

Platform

Additional context

The spin is the while (scheduled || activeTransition) loop in flush() (packages/solid-signals/src/core/scheduler.ts:735-737 — the dev-only guard is at the top of that loop). Root cause, traced by instrumenting each edge of the cycle (per-pass counts in parentheses):

  1. isPending's probe subscribes the user effect to the source's _pendingSignal.
  2. The latest() shadow computed recomputes (×100k), hits NotReadyError, and the lane bookkeeping calls updatePendingSignal (×100k) → setSignal(pendingSignal, true).
  3. That write is optimistic (the pending signal is an optimisticSignal), so it registers with the ambient transition — which completes within the same pass, and resolveOptimisticNodes reverts the override back to false and re-notifies subscribers.
  4. The effect re-runs (×100,001), re-reads, the shadow recomputes, the write happens again — forever. The signal's visible value never leaves false (instrumented: 100,004 consecutive false → true writes), which is also why the pending flag never reads true.

In short: the pending-flag's true is written into a transition that reverts it while the async it describes is still in flight — the revert is the re-notification that sustains the loop.

Suggested fix (validated locally: ~30 lines, three files)

The write being transition-scoped appears intentional — a naive "commit the companion write directly, bypassing the optimistic machinery" variant also fixes the loop but breaks three tests in latest-isPending-consistency.test.ts (store-leaf firewall reporting, the [isPending(x), x()] never-pairs-pending-with-fresh-value guarantee, and sync-memo-over-held-signal visibility). So the defect is narrower: only the revert timing is wrong. The fix defers the revert while the async the companion reports on is still genuinely in flight.

1. types.ts — one field on nodes:

_deferRevert?: () => boolean; // isPending companion policy: keep the optimistic override while true

2. core.ts, getPendingSignal — attach the policy when the companion is created. Two details matter: the check runs against the resolved companion source (parent/firewall for latest() shadow computeds — a shadow can be held by a wider transition after its own source resolved, so its own state is the wrong thing to consult), and it re-resolves at check time because firewall/parent relationships can be established after the signal first exists:

const sig = el._pendingSignal;
sig._deferRevert = () => {
  const src = getCompanionPendingSource(el) as Computed<any>; // parent/firewall for latest() shadows
  return (
    !(src._flags & REACTIVE_DISPOSED) &&
    !!(src._statusFlags & STATUS_PENDING) &&
    !(src._statusFlags & STATUS_UNINITIALIZED)
  );
};

where getCompanionPendingSource is el._parentSource ? (parent._firewall || parent) : (el._firewall || el).

3. scheduler.ts, resolveOptimisticNodes — consult the policy before reverting:

if (node._deferRevert?.()) {
  node._transition = null;
  if (nodes === globalQueue._optimisticNodes) nodes[kept++] = node;
  else if (!globalQueue._optimisticNodes.includes(node))
    globalQueue._optimisticNodes.push(node);
  continue;
}

(plus nodes.length = kept at the end instead of = 0). Each line is load-bearing:

  • node._transition = null — the deferred node otherwise keeps a pointer to the completed transition, and a later companion write would initTransition() that dead object back to life (_done === true is not followed by currentTransition).
  • parking in globalQueue._optimisticNodes — deferred nodes are re-examined on subsequent flushes and resolve into clean committed state once the source settles; leaving them in the completing transition's array would strand them on permanent overrides.
  • the same-array / includes guards — initTransition adopts-and-aliases globalQueue._optimisticNodes into the active transition's array, so the park must not push into the array being iterated, and repeated adopt/defer cycles must not accumulate duplicates.
  • the disposed-source check in the policy — a source disposed mid-flight falls through to the existing revert-to-false path, preserving the "reversion returns to false" safety net for abandoned work.

Validated with: the four loop variants (direct, latest-wrapped, both, plus a latest-only control) no longer spin; isPending(() => latest(x)) turns true on refetch and clears when the memo's own async resolves even while a sibling still holds the transition; the full latest-isPending-consistency.test.ts suite passes; whole-package suite diff vs pristine is exactly the intended flips, nothing else. Five red-first regression tests are written (loop survival, direct-isPending control, own-async-resolve granularity, companion surviving an unrelated flush mid-flight, disposal safety net) — 4 of 5 fail on current next, all pass with the fix.

Perf: for apps not using isPending, the new code is unreachable by construction — resolveOptimisticNodes loops over empty arrays and the policy closure only allocates when isPending first touches a source. Measured write/flush paths are at parity (±0.2%, overlapping across isolated runs).

Alternative shape, if preferred: scope the companion write to the source's transition at write time instead of deferring the revert — architecturally cleaner, but deeper surgery in the lane code. Happy to PR either direction.

Minimal primitive-level repro (no DOM, fails identically), extracted from the bisect used to verify #2829:

const [count, setCount] = createSignal(0);
const asyncValue = createMemo(async () => {
  const c = count();
  await new Promise(r => setTimeout(r, 50));
  return `A${c}`;
});
createRoot(() => {
  createEffect(() => isPending(() => latest(asyncValue)), () => {});
});
// wait for settle …
setCount(1); // → flush() spins; dev throws "Potential Infinite Loop Detected."

Does this exist in Solid 1.x?

Not applicable — 2.0-only surface. Both isPending() and latest() are new 2.0 APIs with no 1.x counterpart.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions