Skip to content

Improve MAX_CASCADING_UPDATES guard: warn with component breakdown instead of silent failure #11222

@sbaleno

Description

@sbaleno

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions