Skip to content

fix: resolve infinite update loop when using createRoot (React 18)#367

Open
zerosrat wants to merge 1 commit intoCJY0208:masterfrom
zerosrat:fix/createroot-infinite-loop
Open

fix: resolve infinite update loop when using createRoot (React 18)#367
zerosrat wants to merge 1 commit intoCJY0208:masterfrom
zerosrat:fix/createroot-infinite-loop

Conversation

@zerosrat
Copy link
Copy Markdown

Problem

When using React 18's createRoot (instead of legacy ReactDOM.render), switching between cached KeepAlive components triggers "Maximum update depth exceeded" errors. This makes react-activation incompatible with createRoot.

Related issues: #336, #257

Root Cause

Two code paths create infinite synchronous render loops under createRoot:

1. FallbackListener.componentDidMount (Suspense.js)

componentDidMount calls onStartsetState({ suspense: true }) synchronously, creating a tight loop:

componentDidMount → setState → re-render → throw promise → 
Suspense fallback → componentDidMount → setState → ...

In React 17 / legacy ReactDOM.render, batching behavior prevented this from becoming an infinite loop. With createRoot, React 18's automatic batching works differently for Suspense fallback rendering, making the cycle synchronous and hitting the max update depth.

2. Keeper uses flushSync (Keeper.js)

flushSync forces synchronous rendering inside createRoot, bypassing React 18's automatic batching and amplifying the infinite loop.

Fix

  1. Suspense.js: Defer onStart callback to a microtask using Promise.resolve().then(), breaking the synchronous cycle. Added an _unmounted guard to prevent calling setState after unmount.

  2. Keeper.js: Remove flushSync wrapper around the freeze setState call. The setTimeout(..., 1000) already provides the necessary delay for post-cache processing.

Testing

Tested with:

  • react@18.3.1 + react-dom@18.3.1
  • react-activation@0.13.4
  • App using createRoot with AliveScope + KeepAlive
  • Verified: switching between cached tabs no longer triggers infinite update errors

🤖 Generated with Claude Code

When using React 18's `createRoot` instead of `ReactDOM.render`, two
code paths cause "Maximum update depth exceeded" errors:

1. FallbackListener.componentDidMount calls setState synchronously,
   creating a tight loop: componentDidMount → setState → re-render →
   throw promise → Suspense fallback → componentDidMount → ...
   Fix: defer onStart to a microtask with Promise.resolve().then(),
   breaking the synchronous cycle. Added unmount guard to prevent
   setState after unmount.

2. Keeper uses flushSync to set freeze state, which forces synchronous
   rendering inside createRoot and bypasses React 18's automatic
   batching, amplifying the infinite loop.
   Fix: remove flushSync wrapper — the setTimeout already provides
   the necessary delay.

Closes CJY0208#336, Closes CJY0208#257

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
@zerosrat zerosrat force-pushed the fix/createroot-infinite-loop branch from aecc90d to c81a4d5 Compare April 16, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant