Context
We're building a social feed (post cards with reactions, comments, share buttons) using @remix-run/component@0.6.0. Each post uses several clientEntry components. With 20 posts on the initial page, we had 92+ hydration regions and hit MAX_CASCADING_UPDATES = 50 in the scheduler.
The error "handle.update() infinite loop detected" fired and left most components unhydrated. No actual infinite loop — just many components hydrating in the same microtask turn.
Minimal reproduction
// app.tsx
import { clientEntry, on, type Handle } from "@remix-run/component";
export const LikeButton = clientEntry(
"/app.tsx#LikeButton",
function LikeButton(handle: Handle) {
let liked = false;
return ({ postId }: { postId: string }) => (
<button
mix={on("click", () => { liked = !liked; handle.update(); })}
style={{ padding: "8px 16px", border: "1px solid #ccc", borderRadius: "4px",
background: liked ? "#ef4444" : "#fff", color: liked ? "#fff" : "#333" }}
>
{liked ? "♥ Liked" : "♡ Like"} ({postId})
</button>
);
}
);
// page.tsx — render 60 LikeButtons
{Array.from({ length: 60 }, (_, i) => (
<LikeButton postId={`post-${i}`} />
))}
Expected: All 60 buttons hydrate and are interactive.
Actual: Hydration stops at 50. Buttons 51-60 render as static HTML but clicks do nothing. Console shows handle.update() infinite loop detected.
Can provide a StackBlitz if helpful.
The limit is actually useful — but the DX needs work
The 50 limit pushed us to optimize. We reduced from 92 to 16 hydration regions by:
- Converting image/avatar components from
clientEntry to server components with onerror CSS fallbacks
- Moving share button logic from
clientEntry to event delegation (data-share-url attributes + global click handler)
- Reducing initial SSR page size from 20 to 5 posts (viewport-only, infinite scroll loads more)
The guard caught a real performance issue. But the experience of discovering and diagnosing it was rough:
-
The error message is opaque. "handle.update() infinite loop detected" gives no component names, no counts, and no indication of whether you're at 51 or 5000. We had to read the scheduler source to understand what was happening.
-
It errors instead of warning. Hitting 50 kills hydration entirely. A console.warn at 50 would give the same signal without breaking the page. Teams would see it, investigate, and optimize — without the "why is nothing interactive?" panic.
-
No way to configure it. We had to use a pnpm patch. Something like run({ hydrationWarnLimit: 50, hydrationErrorLimit: 200 }) would let teams tune for their use case.
-
Per-component tracking for the hard error. 51 different components each updating once isn't a loop. One component updating 51 times is. The hard error (infinite loop detection) would be more accurate as a per-component counter, while the global counter could remain as a warning/optimization signal.
What we patched locally
We ended up patching the scheduler to get better diagnostics:
- Warns at 50 with a component breakdown:
[Hydration] 50 cascading updates — consider reducing hydration regions. Components: ReactionPicker×5, ShareButton×5
- Errors at 200 with the same breakdown in the error message
- Resets the warn flag each event loop turn
This turned a confusing breakage into an actionable optimization signal.
Proposed patch
Here's the patch we're running locally via pnpm patchedDependencies. Happy to open a PR if this direction is useful.
Changes to scheduler.js:
// Protect against infinite cascading updates (e.g. handle.update() during render)
-const MAX_CASCADING_UPDATES = 50;
+const MAX_CASCADING_UPDATES = 200;
+const CASCADING_UPDATES_WARN = 50;
+let _warnedThisTurn = false;
Reset warn flag alongside the counter:
setTimeout(() => {
cascadingUpdateCount = 0;
resetScheduled = false;
+ _warnedThisTurn = false;
}, 0);
Warn at 50 with component breakdown, error at 200 with the same:
cascadingUpdateCount++;
scheduleCounterReset();
+ if (cascadingUpdateCount === CASCADING_UPDATES_WARN && !_warnedThisTurn) {
+ _warnedThisTurn = true;
+ let names = [...batch.keys()].map(v => v.type?.name || 'anonymous');
+ let counts = {};
+ names.forEach(n => counts[n] = (counts[n] || 0) + 1);
+ console.warn(
+ '[Hydration] ' + cascadingUpdateCount +
+ ' cascading updates — consider reducing hydration regions. Components in this batch:',
+ Object.entries(counts).map(([n, c]) => n + '×' + c).join(', ')
+ );
+ }
if (cascadingUpdateCount > MAX_CASCADING_UPDATES) {
- let error = new Error('handle.update() infinite loop detected');
+ let names = [...batch.keys()].map(v => v.type?.name || 'anonymous');
+ let counts = {};
+ names.forEach(n => counts[n] = (counts[n] || 0) + 1);
+ let error = new Error(
+ 'handle.update() infinite loop detected (' + cascadingUpdateCount +
+ ' updates). Components: ' +
+ Object.entries(counts).map(([n, c]) => n + '×' + c).join(', ')
+ );
dispatchError(error);
return;
}
Summary of suggestions
| Current |
Suggested |
| Hard error at 50 |
Warn at 50, hard error at higher limit |
"infinite loop detected" |
Include component names and counts in message |
| Not configurable |
run({ hydrationWarnLimit, hydrationErrorLimit }) |
| Global counter only |
Per-component counter for infinite loop detection |
Environment
Context
We're building a social feed (post cards with reactions, comments, share buttons) using
@remix-run/component@0.6.0. Each post uses severalclientEntrycomponents. With 20 posts on the initial page, we had 92+ hydration regions and hitMAX_CASCADING_UPDATES = 50in the scheduler.The error
"handle.update() infinite loop detected"fired and left most components unhydrated. No actual infinite loop — just many components hydrating in the same microtask turn.Minimal reproduction
Expected: All 60 buttons hydrate and are interactive.
Actual: Hydration stops at 50. Buttons 51-60 render as static HTML but clicks do nothing. Console shows
handle.update() infinite loop detected.Can provide a StackBlitz if helpful.
The limit is actually useful — but the DX needs work
The 50 limit pushed us to optimize. We reduced from 92 to 16 hydration regions by:
clientEntryto server components withonerrorCSS fallbacksclientEntryto event delegation (data-share-urlattributes + global click handler)The guard caught a real performance issue. But the experience of discovering and diagnosing it was rough:
The error message is opaque.
"handle.update() infinite loop detected"gives no component names, no counts, and no indication of whether you're at 51 or 5000. We had to read the scheduler source to understand what was happening.It errors instead of warning. Hitting 50 kills hydration entirely. A
console.warnat 50 would give the same signal without breaking the page. Teams would see it, investigate, and optimize — without the "why is nothing interactive?" panic.No way to configure it. We had to use a pnpm patch. Something like
run({ hydrationWarnLimit: 50, hydrationErrorLimit: 200 })would let teams tune for their use case.Per-component tracking for the hard error. 51 different components each updating once isn't a loop. One component updating 51 times is. The hard error (infinite loop detection) would be more accurate as a per-component counter, while the global counter could remain as a warning/optimization signal.
What we patched locally
We ended up patching the scheduler to get better diagnostics:
[Hydration] 50 cascading updates — consider reducing hydration regions. Components: ReactionPicker×5, ShareButton×5This turned a confusing breakage into an actionable optimization signal.
Proposed patch
Here's the patch we're running locally via pnpm
patchedDependencies. Happy to open a PR if this direction is useful.Changes to
scheduler.js:Reset warn flag alongside the counter:
setTimeout(() => { cascadingUpdateCount = 0; resetScheduled = false; + _warnedThisTurn = false; }, 0);Warn at 50 with component breakdown, error at 200 with the same:
cascadingUpdateCount++; scheduleCounterReset(); + if (cascadingUpdateCount === CASCADING_UPDATES_WARN && !_warnedThisTurn) { + _warnedThisTurn = true; + let names = [...batch.keys()].map(v => v.type?.name || 'anonymous'); + let counts = {}; + names.forEach(n => counts[n] = (counts[n] || 0) + 1); + console.warn( + '[Hydration] ' + cascadingUpdateCount + + ' cascading updates — consider reducing hydration regions. Components in this batch:', + Object.entries(counts).map(([n, c]) => n + '×' + c).join(', ') + ); + } if (cascadingUpdateCount > MAX_CASCADING_UPDATES) { - let error = new Error('handle.update() infinite loop detected'); + let names = [...batch.keys()].map(v => v.type?.name || 'anonymous'); + let counts = {}; + names.forEach(n => counts[n] = (counts[n] || 0) + 1); + let error = new Error( + 'handle.update() infinite loop detected (' + cascadingUpdateCount + + ' updates). Components: ' + + Object.entries(counts).map(([n, c]) => n + '×' + c).join(', ') + ); dispatchError(error); return; }Summary of suggestions
"infinite loop detected"run({ hydrationWarnLimit, hydrationErrorLimit })Environment
@remix-run/component@0.6.00951cf49)