You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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.
In prod the guard is __DEV__-only, so the while (scheduled) loop in flush() spins forever — pegged CPU, permanently unresponsive app.
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.
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";constdelay=(ms: number)=>newPromise(r=>setTimeout(r,ms));exportdefaultfunctionApp(){const[version,refetch]=createSignal(0);letstatusEl!: HTMLElement;leterrLog!: HTMLPreElement;// an async source that refetches whenever `version` bumpsconstdata=createMemo(async()=>{constv=version();awaitdelay(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(<mainstyle={{"font-family": "system-ui",padding: "16px"}}><h2>isPending(() => latest(x)) loops the scheduler on refetch</h2><p>
status: <bref={statusEl}>…</b></p><buttononClick={()=>refetch(v=>v+1)}>refetch</button><preref={errLog}>uncaught errors:{"\n"}</pre></main>);}
Steps to Reproduce the Bug or Issue
Wait for the initial fetch to settle (~300ms): the status reads idle.
Click refetch once. Actual — the page stutters while the scheduler spins 100,000 iterations, then the on-page log (and console) shows:
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.
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):
isPending's probe subscribes the user effect to the source's _pendingSignal.
The latest() shadow computed recomputes (×100k), hits NotReadyError, and the lane bookkeeping calls updatePendingSignal (×100k) → setSignal(pendingSignal, true).
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.
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:
constsig=el._pendingSignal;sig._deferRevert=()=>{constsrc=getCompanionPendingSource(el)asComputed<any>;// parent/firewall for latest() shadowsreturn(!(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:
(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-aliasesglobalQueue._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);constasyncValue=createMemo(async()=>{constc=count();awaitnewPromise(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.
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:flush()'s iteration guard fires after 100,000 spins: an uncaughtError: Potential Infinite Loop Detected.from the microtask-scheduled flush, after a visible stutter.__DEV__-only, so thewhile (scheduled)loop inflush()spins forever — pegged CPU, permanently unresponsive app.trueat 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):
isPending(() => latest(m))—isPending(m)directly does not loop;createEffect) — the same expression in JSX (a render effect) does not loop;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 Detectedin the on-page error log (and the status never showsrevalidating…). Fixed = status flips torevalidating…, back toidle~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;
datais deliberately not rendered anywhere (see the masking condition above):Steps to Reproduce the Bug or Issue
idle.and the status still reads
idle— it never showedrevalidating…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 toidle~300ms later, and no error is thrown: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
next@e2ebc11c— i.e. after the 2.0.0-beta.15: A few latest() bugs #2829 fix536bea5f, the 2.0.0-beta.15:isPending(() => latest(x))is inconsistent across node shapes #2831 fix16c861ea, and thelatest()/isPending()internals refactor76fc7e67; the loop survives all three)Additional context
The spin is the
while (scheduled || activeTransition)loop inflush()(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):isPending's probe subscribes the user effect to the source's_pendingSignal.latest()shadow computed recomputes (×100k), hitsNotReadyError, and the lane bookkeeping callsupdatePendingSignal(×100k) →setSignal(pendingSignal, true).optimisticSignal), so it registers with the ambient transition — which completes within the same pass, andresolveOptimisticNodesreverts the override back tofalseand re-notifies subscribers.false(instrumented: 100,004 consecutivefalse → truewrites), which is also why the pending flag never readstrue.In short: the pending-flag's
trueis 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: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 forlatest()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:where
getCompanionPendingSourceisel._parentSource ? (parent._firewall || parent) : (el._firewall || el).3.
scheduler.ts,resolveOptimisticNodes— consult the policy before reverting:(plus
nodes.length = keptat 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 wouldinitTransition()that dead object back to life (_done === trueis not followed bycurrentTransition).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.includesguards —initTransitionadopts-and-aliasesglobalQueue._optimisticNodesinto 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.Validated with: the four loop variants (direct, latest-wrapped, both, plus a
latest-only control) no longer spin;isPending(() => latest(x))turnstrueon refetch and clears when the memo's own async resolves even while a sibling still holds the transition; the fulllatest-isPending-consistency.test.tssuite passes; whole-package suite diff vs pristine is exactly the intended flips, nothing else. Five red-first regression tests are written (loop survival, direct-isPendingcontrol, own-async-resolve granularity, companion surviving an unrelated flush mid-flight, disposal safety net) — 4 of 5 fail on currentnext, all pass with the fix.Perf: for apps not using
isPending, the new code is unreachable by construction —resolveOptimisticNodesloops over empty arrays and the policy closure only allocates whenisPendingfirst 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:
Does this exist in Solid 1.x?
Not applicable — 2.0-only surface. Both
isPending()andlatest()are new 2.0 APIs with no 1.x counterpart.