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