From f4e0d4ed0cb44f5f106579d20824f49ec41247f3 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:12:57 +0100 Subject: [PATCH] [enableInfiniteRenderLoopDetection] Add a flag to force throwing (#36357) Adds a `enableInfiniteRenderLoopDetectionForceThrow` flag, which changes the detection mechanism behaviour to start throwing, when the loop is observed. By default, the value is set to `false`, also made dynamic from the start for `www`. --- .../src/__tests__/ReactUpdates-test.js | 154 +++++++++++++++++- .../src/ReactFiberWorkLoop.js | 41 ++++- packages/shared/ReactFeatureFlags.js | 6 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + scripts/error-codes/codes.json | 3 +- 11 files changed, 203 insertions(+), 8 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index 2e79c33644e9..503f3f5c32cb 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -1793,7 +1793,14 @@ describe('ReactUpdates', () => { }); it("warns about potential infinite loop if there's a synchronous render phase update on another component", async () => { - if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) { + if ( + !__DEV__ || + gate( + flags => + !flags.enableInfiniteRenderLoopDetection || + flags.enableInfiniteRenderLoopDetectionForceThrow, + ) + ) { return; } let setState; @@ -1831,7 +1838,14 @@ describe('ReactUpdates', () => { }); it("warns about potential infinite loop if there's an async render phase update on another component", async () => { - if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) { + if ( + !__DEV__ || + gate( + flags => + !flags.enableInfiniteRenderLoopDetection || + flags.enableInfiniteRenderLoopDetectionForceThrow, + ) + ) { return; } let setState; @@ -2007,7 +2021,14 @@ 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)) { + if ( + !__DEV__ || + gate( + flags => + !flags.enableInfiniteRenderLoopDetection || + flags.enableInfiniteRenderLoopDetectionForceThrow, + ) + ) { return; } @@ -2115,6 +2136,133 @@ describe('ReactUpdates', () => { expect(errors).toEqual([]); }); + // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow + it('throws when sync render-phase update loop is detected with force-throw enabled', async () => { + // Render-phase setState on another component's hook produces a sync + // recursive update. With ForceThrow enabled this should throw via + // throwForcedInfiniteRenderLoopError instead of only warning in DEV. + let setState; + let shouldStop = false; + function App() { + const [, _setState] = React.useState(0); + setState = _setState; + return ; + } + + function Child() { + if (shouldStop) { + return null; + } + setState(n => n + 1); + return null; + } + + const container = document.createElement('div'); + const errors = []; + const captureError = error => { + errors.push(error.message); + // Stop scheduling new updates so the test (and the gate-off variant + // where the legacy error path is recoverable) can terminate cleanly + // without tripping the babel infinite-loop guard. + shouldStop = true; + }; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: captureError, + onRecoverableError: captureError, + onCaughtError: captureError, + }); + + // The render-phase setState path also produces a dev-only "Cannot update + // a component while rendering a different component" console.error on + // every recursion. Swallow those so the test framework doesn't require + // us to assert each one. + const originalConsoleError = console.error; + console.error = msg => { + if ( + typeof msg === 'string' && + msg.startsWith('Cannot update a component') + ) { + return; + } + originalConsoleError(msg); + }; + try { + await act(() => { + root.render(); + }); + } finally { + console.error = originalConsoleError; + } + + expect(errors.length).toBeGreaterThanOrEqual(1); + expect(errors[0]).toContain( + 'Maximum update depth exceeded. This could be an infinite loop.', + ); + }); + + // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow + it('throws when phase-spawn update loop is detected with force-throw enabled', async () => { + // Wrapping the initial render in startTransition makes the render-phase + // setState inherit a non-sync transition lane. After commit, the next + // render is non-sync, so the loop detector classifies the recursion as + // NESTED_UPDATE_PHASE_SPAWN (rather than SYNC_LANE). With ForceThrow + // enabled, this branch should throw via throwForcedInfiniteRenderLoopError + // instead of only warning in DEV. + let setState; + let shouldStop = false; + // Hard cap on Child renders. Without enableInfiniteRenderLoopDetection, + // the PHASE_SPAWN branch is gated off entirely, so no throw fires and + // the loop would otherwise run until the babel infinite-loop guard. + let renderCount = 0; + const RENDER_CAP = 100; + function App() { + const [, _setState] = React.useState(0); + setState = _setState; + return ; + } + + function Child() { + if (shouldStop || renderCount >= RENDER_CAP) { + return null; + } + renderCount++; + setState(n => n + 1); + return null; + } + + const container = document.createElement('div'); + const errors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: error => { + errors.push(error.message); + shouldStop = true; + }, + }); + + const originalConsoleError = console.error; + console.error = msg => { + if ( + typeof msg === 'string' && + msg.startsWith('Cannot update a component') + ) { + return; + } + originalConsoleError(msg); + }; + try { + await act(() => { + React.startTransition(() => root.render()); + }); + } finally { + console.error = originalConsoleError; + } + + expect(errors.length).toBeGreaterThanOrEqual(1); + expect(errors[0]).toContain( + 'Maximum update depth exceeded. This could be an infinite loop.', + ); + }); + 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/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 17d5e311c535..d45a2d18cf93 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -51,6 +51,7 @@ import { disableLegacyContext, alwaysThrottleRetries, enableInfiniteRenderLoopDetection, + enableInfiniteRenderLoopDetectionForceThrow, disableLegacyMode, enableComponentPerformanceTrack, enableYieldingBeforePassive, @@ -5175,6 +5176,27 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) { retryTimedOutBoundary(boundaryFiber, retryLane); } +function throwForcedInfiniteRenderLoopError( + root: FiberRoot | null, + renderLanes: Lanes, +): empty { + if (root !== null) { + // Disable concurrent error recovery for the in-progress render so the thrown + // error reaches the nearest error boundary and breaks the infinite update + // loop instead of being silently retried by the recovery mechanism. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + renderLanes, + ); + } + throw new Error( + 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' + + 'repeatedly calls setState during render phase or inside useLayoutEffect, ' + + 'causing infinite render loop. React limits the number of nested updates to ' + + 'prevent infinite loops.', + ); +} + export function throwIfInfiniteUpdateLoopDetected( isFromInfiniteRenderLoopDetectionInstrumentation: boolean, ) { @@ -5191,10 +5213,16 @@ export function throwIfInfiniteUpdateLoopDetected( if (updateKind === NESTED_UPDATE_SYNC_LANE) { if ( isFromInfiniteRenderLoopDetectionInstrumentation || - (executionContext & RenderContext && workInProgressRoot !== null) + (executionContext & RenderContext) !== NoContext ) { - // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing. - if (__DEV__) { + // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, + // warn instead of throwing, unless enableInfiniteRenderLoopDetectionForceThrow. + if (enableInfiniteRenderLoopDetectionForceThrow) { + throwForcedInfiniteRenderLoopError( + workInProgressRoot, + workInProgressRootRenderLanes, + ); + } else if (__DEV__) { console.error( 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' + 'repeatedly calls setState during render phase or inside useLayoutEffect, ' + @@ -5211,7 +5239,12 @@ export function throwIfInfiniteUpdateLoopDetected( ); } } else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) { - if (__DEV__) { + if (enableInfiniteRenderLoopDetectionForceThrow) { + throwForcedInfiniteRenderLoopError( + workInProgressRoot, + workInProgressRootRenderLanes, + ); + } else if (__DEV__) { console.error( 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' + 'repeatedly calls setState during render phase or inside useLayoutEffect, ' + diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index c1d12dfcfbd4..5d2d6efaa7df 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -144,6 +144,12 @@ export const transitionLaneExpirationMs = 5000; * by setState or similar outside of the component owning the state. */ export const enableInfiniteRenderLoopDetection: boolean = false; +/** + * When `enableInfiniteRenderLoopDetection` is on, forces the detection + * mechanism to throw instead of only warning in cases where it would + * otherwise downgrade to a warning. + */ +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index e420b5443cc4..96d13e9ec461 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -47,6 +47,7 @@ export const enableCreateEventHandleAPI: boolean = false; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; export const enableLegacyCache: boolean = false; export const enableLegacyFBSupport: boolean = false; export const enableLegacyHidden: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index d37dff7f34e9..3c452162ab98 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -31,6 +31,7 @@ export const enableCreateEventHandleAPI: boolean = false; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; export const enableLegacyCache: boolean = false; export const enableLegacyFBSupport: boolean = false; export const enableLegacyHidden: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 537448d3904a..309de96b4951 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -52,6 +52,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false; export const disableClientCache: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; export const enableEagerAlternateStateNodeCleanup: boolean = true; export const enableEffectEventMutationPhase: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 8d97f9dd9ddd..af8dd955d0ba 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -26,6 +26,7 @@ export const enableCreateEventHandleAPI = false; export const enableMoveBefore = false; export const enableFizzExternalRuntime = true; export const enableInfiniteRenderLoopDetection = false; +export const enableInfiniteRenderLoopDetectionForceThrow = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; export const enableLegacyHidden = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 53b8b487df3b..e57495ed4e53 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -54,6 +54,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false; export const disableClientCache: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; export const enableReactTestRendererWarning: boolean = false; export const disableLegacyMode: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index e19b3314c19b..d3340f6eb940 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -27,6 +27,7 @@ export const transitionLaneExpirationMs = 5000; export const enableSchedulingProfiler: boolean = __VARIANT__; export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__; +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__; export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__; export const enableSuspenseyImages: boolean = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 740a3f3f845b..062cbaa4268a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -20,6 +20,7 @@ export const { disableSchedulerTimeoutInWorkLoop, enableEffectEventMutationPhase, enableInfiniteRenderLoopDetection, + enableInfiniteRenderLoopDetectionForceThrow, enableNoCloningMemoCache, enableObjectFiber, enableRetryLaneExpiration, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 345aa2b0aaf0..4b23d5ea96d3 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -582,5 +582,6 @@ "594": "Cannot read Symbol exports. Only named exports are supported on a client module imported on the server.", "595": "Attempted to call %s() from the server but %s is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.", "596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.", - "597": "The module \"%s\" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler." + "597": "The module \"%s\" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler.", + "598": "Maximum update depth exceeded. This could be an infinite loop. This can happen when a component repeatedly calls setState during render phase or inside useLayoutEffect, causing infinite render loop. React limits the number of nested updates to prevent infinite loops." } \ No newline at end of file