From 4ec0db731ecc93ea1b887d020529ec774f38e686 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Thu, 2 Apr 2026 12:18:04 +0100 Subject: [PATCH] fix: explicitly warn for infinite loops discovered only via enableInfiniteRenderLoopDetection --- .../src/__tests__/ReactUpdates-test.js | 109 ++++++++++++++++++ .../src/ReactFiberConcurrentUpdates.js | 2 +- .../src/ReactFiberWorkLoop.js | 13 ++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index cf2e958d4511..2e1541edf5ac 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -2006,6 +2006,115 @@ describe('ReactUpdates', () => { ]); }); + it('warns instead of throwing when infinite Suspense ping loop is detected via enableInfiniteRenderLoopDetection during commit phase', async () => { + if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) { + return; + } + + // When a Suspense child throws a thenable, React registers two listeners: + // 1. ping (attachPingListener, render) → pingSuspendedRoot → markRootPinged + // 2. retry (attachSuspenseRetryListeners, commit) → resolveRetryWakeable + // + // The ping path calls throwIfInfiniteUpdateLoopDetected(true) via + // markRootPinged WITHOUT a prior getRootForUpdatedFiber(false) check. + // When this fires during CommitContext (not RenderContext), + // the isFromInfiniteRenderLoopDetectionInstrumentation=true parameter + // ensures we warn instead of throw. + // + // Without the fix (passing false), the condition + // false || (executionContext & RenderContext && ...) + // evaluates to false in CommitContext, causing a throw. + let currentResolve = null; + let shouldStop = false; + + function App() { + const [, setState] = React.useState(0); + + React.useLayoutEffect(() => { + if (shouldStop) { + return; + } + // Resolve the suspended thenable during commit phase (CommitContext). + // The ping callback (registered first during render) fires first, + // triggering markRootPinged → throwIfInfiniteUpdateLoopDetected(true). + if (currentResolve !== null) { + const resolve = currentResolve; + currentResolve = null; + resolve(); + } + // Schedule a sync update to ensure nestedUpdateKind is + // NESTED_UPDATE_SYNC_LANE at commitRootImpl epilogue. + setState(n => n + 1); + }); + + return ( + + + + ); + } + + function SuspendingChild() { + if (shouldStop) { + return null; + } + // Each render throws a new thenable. React calls .then() on it twice + // (ping during render, retry during commit). We collect all callbacks + // so resolve() fires them in registration order: ping first. + const callbacks = []; + const thenable = { + then(onFulfilled) { + callbacks.push(onFulfilled); + currentResolve = () => { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + }; + }, + }; + + throw thenable; + } + + const container = document.createElement('div'); + const errors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: error => { + errors.push(error.message); + }, + }); + + const originalConsoleError = console.error; + console.error = e => { + if ( + typeof e === 'string' && + e.startsWith( + 'Maximum update depth exceeded. This could be an infinite loop.', + ) + ) { + // Stop the loop after the first warning so act() can finish. + shouldStop = true; + } + }; + + try { + await act(() => { + root.render(); + }); + } finally { + console.error = originalConsoleError; + } + + // With the fix (throwIfInfiniteUpdateLoopDetected(true) in markRootPinged): + // the loop is discovered via enableInfiniteRenderLoopDetection instrumentation + // and produces a warning. + // Without the fix (throwIfInfiniteUpdateLoopDetected(false)): + // the same check throws because executionContext is CommitContext, not + // RenderContext. + expect(shouldStop).toBe(true); + expect(errors).toEqual([]); + }); + it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => { let scheduleUpdate; function TooManyRefUpdates() { diff --git a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js index 05aeb3cfbb37..f8c67a1c3fed 100644 --- a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js +++ b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js @@ -254,7 +254,7 @@ function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null { // current behavior we've used for several release cycles. Consider not // performing this check if the updated fiber already unmounted, since it's // not possible for that to cause an infinite update loop. - throwIfInfiniteUpdateLoopDetected(); + throwIfInfiniteUpdateLoopDetected(false); // When a setState happens, we must ensure the root is scheduled. Because // update queues do not have a backpointer to the root, the only way to do diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 9a3953c1b5a2..d1153a201dfc 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1754,7 +1754,7 @@ function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) { didIncludeCommitPhaseUpdate = true; } - throwIfInfiniteUpdateLoopDetected(); + throwIfInfiniteUpdateLoopDetected(true); } } @@ -1773,7 +1773,7 @@ function markRootPinged(root: FiberRoot, pingedLanes: Lanes) { didIncludeCommitPhaseUpdate = true; } - throwIfInfiniteUpdateLoopDetected(); + throwIfInfiniteUpdateLoopDetected(true); } } @@ -5175,7 +5175,9 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) { retryTimedOutBoundary(boundaryFiber, retryLane); } -export function throwIfInfiniteUpdateLoopDetected() { +export function throwIfInfiniteUpdateLoopDetected( + isFromInfiniteRenderLoopDetectionInstrumentation: boolean, +) { if (nestedUpdateCount > NESTED_UPDATE_LIMIT) { nestedUpdateCount = 0; nestedPassiveUpdateCount = 0; @@ -5187,7 +5189,10 @@ export function throwIfInfiniteUpdateLoopDetected() { if (enableInfiniteRenderLoopDetection) { if (updateKind === NESTED_UPDATE_SYNC_LANE) { - if (executionContext & RenderContext && workInProgressRoot !== null) { + if ( + isFromInfiniteRenderLoopDetectionInstrumentation || + (executionContext & RenderContext && workInProgressRoot !== null) + ) { // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing. if (__DEV__) { console.error(