[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`.
Ruslan Lesiutin committed
Apr 29, 2026 at 21:12 UTC
f4e0d4ed0cb44f5f106579d20824f49ec41247f3
11 files changed
+203
-8
packages/react-dom/src/__tests__/ReactUpdates-test.js
+151
-3
@@ -1793,7 +1793,14 @@ describe('ReactUpdates', () => {
1793
});
1794
1795
it("warns about potential infinite loop if there's a synchronous render phase update on another component", async () => {
1796
- if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1796
+ if (
1797
+ !__DEV__ ||
1798
+ gate(
1799
+ flags =>
1800
+ !flags.enableInfiniteRenderLoopDetection ||
1801
+ flags.enableInfiniteRenderLoopDetectionForceThrow,
1802
+ )
1803
+ ) {
1804
return;
1805
}
1806
let setState;
@@ -1831,7 +1838,14 @@ describe('ReactUpdates', () => {
1838
});
1839
1840
it("warns about potential infinite loop if there's an async render phase update on another component", async () => {
1834
- if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1841
+ if (
1842
+ !__DEV__ ||
1843
+ gate(
1844
+ flags =>
1845
+ !flags.enableInfiniteRenderLoopDetection ||
1846
+ flags.enableInfiniteRenderLoopDetectionForceThrow,
1847
+ )
1848
+ ) {
1849
return;
1850
}
1851
let setState;
@@ -2007,7 +2021,14 @@ describe('ReactUpdates', () => {
2021
});
2022
2023
it('warns instead of throwing when infinite Suspense ping loop is detected via enableInfiniteRenderLoopDetection during commit phase', async () => {
2010
- if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
2024
+ if (
2025
+ !__DEV__ ||
2026
+ gate(
2027
+ flags =>
2028
+ !flags.enableInfiniteRenderLoopDetection ||
2029
+ flags.enableInfiniteRenderLoopDetectionForceThrow,
2030
+ )
2031
+ ) {
2032
return;
2033
}
2034
@@ -2115,6 +2136,133 @@ describe('ReactUpdates', () => {
2136
expect(errors).toEqual([]);
2137
});
2138
2139
+ // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
2140
+ it('throws when sync render-phase update loop is detected with force-throw enabled', async () => {
2141
+ // Render-phase setState on another component's hook produces a sync
2142
+ // recursive update. With ForceThrow enabled this should throw via
2143
+ // throwForcedInfiniteRenderLoopError instead of only warning in DEV.
2144
+ let setState;
2145
+ let shouldStop = false;
2146
+ function App() {
2147
+ const [, _setState] = React.useState(0);
2148
+ setState = _setState;
2149
+ return <Child />;
2150
+ }
2151
+
2152
+ function Child() {
2153
+ if (shouldStop) {
2154
+ return null;
2155
+ }
2156
+ setState(n => n + 1);
2157
+ return null;
2158
+ }
2159
+
2160
+ const container = document.createElement('div');
2161
+ const errors = [];
2162
+ const captureError = error => {
2163
+ errors.push(error.message);
2164
+ // Stop scheduling new updates so the test (and the gate-off variant
2165
+ // where the legacy error path is recoverable) can terminate cleanly
2166
+ // without tripping the babel infinite-loop guard.
2167
+ shouldStop = true;
2168
+ };
2169
+ const root = ReactDOMClient.createRoot(container, {
2170
+ onUncaughtError: captureError,
2171
+ onRecoverableError: captureError,
2172
+ onCaughtError: captureError,
2173
+ });
2174
+
2175
+ // The render-phase setState path also produces a dev-only "Cannot update
2176
+ // a component while rendering a different component" console.error on
2177
+ // every recursion. Swallow those so the test framework doesn't require
2178
+ // us to assert each one.
2179
+ const originalConsoleError = console.error;
2180
+ console.error = msg => {
2181
+ if (
2182
+ typeof msg === 'string' &&
2183
+ msg.startsWith('Cannot update a component')
2184
+ ) {
2185
+ return;
2186
+ }
2187
+ originalConsoleError(msg);
2188
+ };
2189
+ try {
2190
+ await act(() => {
2191
+ root.render(<App />);
2192
+ });
2193
+ } finally {
2194
+ console.error = originalConsoleError;
2195
+ }
2196
+
2197
+ expect(errors.length).toBeGreaterThanOrEqual(1);
2198
+ expect(errors[0]).toContain(
2199
+ 'Maximum update depth exceeded. This could be an infinite loop.',
2200
+ );
2201
+ });
2202
+
2203
+ // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
2204
+ it('throws when phase-spawn update loop is detected with force-throw enabled', async () => {
2205
+ // Wrapping the initial render in startTransition makes the render-phase
2206
+ // setState inherit a non-sync transition lane. After commit, the next
2207
+ // render is non-sync, so the loop detector classifies the recursion as
2208
+ // NESTED_UPDATE_PHASE_SPAWN (rather than SYNC_LANE). With ForceThrow
2209
+ // enabled, this branch should throw via throwForcedInfiniteRenderLoopError
2210
+ // instead of only warning in DEV.
2211
+ let setState;
2212
+ let shouldStop = false;
2213
+ // Hard cap on Child renders. Without enableInfiniteRenderLoopDetection,
2214
+ // the PHASE_SPAWN branch is gated off entirely, so no throw fires and
2215
+ // the loop would otherwise run until the babel infinite-loop guard.
2216
+ let renderCount = 0;
2217
+ const RENDER_CAP = 100;
2218
+ function App() {
2219
+ const [, _setState] = React.useState(0);
2220
+ setState = _setState;
2221
+ return <Child />;
2222
+ }
2223
+
2224
+ function Child() {
2225
+ if (shouldStop || renderCount >= RENDER_CAP) {
2226
+ return null;
2227
+ }
2228
+ renderCount++;
2229
+ setState(n => n + 1);
2230
+ return null;
2231
+ }
2232
+
2233
+ const container = document.createElement('div');
2234
+ const errors = [];
2235
+ const root = ReactDOMClient.createRoot(container, {
2236
+ onUncaughtError: error => {
2237
+ errors.push(error.message);
2238
+ shouldStop = true;
2239
+ },
2240
+ });
2241
+
2242
+ const originalConsoleError = console.error;
2243
+ console.error = msg => {
2244
+ if (
2245
+ typeof msg === 'string' &&
2246
+ msg.startsWith('Cannot update a component')
2247
+ ) {
2248
+ return;
2249
+ }
2250
+ originalConsoleError(msg);
2251
+ };
2252
+ try {
2253
+ await act(() => {
2254
+ React.startTransition(() => root.render(<App />));
2255
+ });
2256
+ } finally {
2257
+ console.error = originalConsoleError;
2258
+ }
2259
+
2260
+ expect(errors.length).toBeGreaterThanOrEqual(1);
2261
+ expect(errors[0]).toContain(
2262
+ 'Maximum update depth exceeded. This could be an infinite loop.',
2263
+ );
2264
+ });
2265
+
2266
it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
2267
let scheduleUpdate;
2268
function TooManyRefUpdates() {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+37
-4
@@ -51,6 +51,7 @@ import {
51
disableLegacyContext,
52
alwaysThrottleRetries,
53
enableInfiniteRenderLoopDetection,
54
+ enableInfiniteRenderLoopDetectionForceThrow,
55
disableLegacyMode,
56
enableComponentPerformanceTrack,
57
enableYieldingBeforePassive,
@@ -5175,6 +5176,27 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
5176
retryTimedOutBoundary(boundaryFiber, retryLane);
5177
}
5178
5179
+function throwForcedInfiniteRenderLoopError(
5180
+ root: FiberRoot | null,
5181
+ renderLanes: Lanes,
5182
+): empty {
5183
+ if (root !== null) {
5184
+ // Disable concurrent error recovery for the in-progress render so the thrown
5185
+ // error reaches the nearest error boundary and breaks the infinite update
5186
+ // loop instead of being silently retried by the recovery mechanism.
5187
+ root.errorRecoveryDisabledLanes = mergeLanes(
5188
+ root.errorRecoveryDisabledLanes,
5189
+ renderLanes,
5190
+ );
5191
+ }
5192
+ throw new Error(
5193
+ 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
5194
+ 'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
5195
+ 'causing infinite render loop. React limits the number of nested updates to ' +
5196
+ 'prevent infinite loops.',
5197
+ );
5198
+}
5199
+
5200
export function throwIfInfiniteUpdateLoopDetected(
5201
isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
5202
) {
@@ -5191,10 +5213,16 @@ export function throwIfInfiniteUpdateLoopDetected(
5213
if (updateKind === NESTED_UPDATE_SYNC_LANE) {
5214
if (
5215
isFromInfiniteRenderLoopDetectionInstrumentation ||
5194
- (executionContext & RenderContext && workInProgressRoot !== null)
5216
+ (executionContext & RenderContext) !== NoContext
5217
) {
5196
- // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
5197
- if (__DEV__) {
5218
+ // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection,
5219
+ // warn instead of throwing, unless enableInfiniteRenderLoopDetectionForceThrow.
5220
+ if (enableInfiniteRenderLoopDetectionForceThrow) {
5221
+ throwForcedInfiniteRenderLoopError(
5222
+ workInProgressRoot,
5223
+ workInProgressRootRenderLanes,
5224
+ );
5225
+ } else if (__DEV__) {
5226
console.error(
5227
'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
5228
'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
@@ -5211,7 +5239,12 @@ export function throwIfInfiniteUpdateLoopDetected(
5239
);
5240
}
5241
} else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) {
5214
- if (__DEV__) {
5242
+ if (enableInfiniteRenderLoopDetectionForceThrow) {
5243
+ throwForcedInfiniteRenderLoopError(
5244
+ workInProgressRoot,
5245
+ workInProgressRootRenderLanes,
5246
+ );
5247
+ } else if (__DEV__) {
5248
console.error(
5249
'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
5250
'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
packages/shared/ReactFeatureFlags.js
+6
@@ -144,6 +144,12 @@ export const transitionLaneExpirationMs = 5000;
144
* by setState or similar outside of the component owning the state.
145
*/
146
export const enableInfiniteRenderLoopDetection: boolean = false;
147
+/**
148
+ * When `enableInfiniteRenderLoopDetection` is on, forces the detection
149
+ * mechanism to throw instead of only warning in cases where it would
150
+ * otherwise downgrade to a warning.
151
+ */
152
+export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
153
154
export const enableFragmentRefs: boolean = true;
155
export const enableFragmentRefsScrollIntoView: boolean = true;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -47,6 +47,7 @@ export const enableCreateEventHandleAPI: boolean = false;
47
export const enableMoveBefore: boolean = true;
48
export const enableFizzExternalRuntime: boolean = true;
49
export const enableInfiniteRenderLoopDetection: boolean = false;
50
+export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
51
export const enableLegacyCache: boolean = false;
52
export const enableLegacyFBSupport: boolean = false;
53
export const enableLegacyHidden: boolean = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -31,6 +31,7 @@ export const enableCreateEventHandleAPI: boolean = false;
31
export const enableMoveBefore: boolean = true;
32
export const enableFizzExternalRuntime: boolean = true;
33
export const enableInfiniteRenderLoopDetection: boolean = false;
34
+export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
35
export const enableLegacyCache: boolean = false;
36
export const enableLegacyFBSupport: boolean = false;
37
export const enableLegacyHidden: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -52,6 +52,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
52
export const disableClientCache: boolean = true;
53
54
export const enableInfiniteRenderLoopDetection: boolean = false;
55
+export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
56
57
export const enableEagerAlternateStateNodeCleanup: boolean = true;
58
export const enableEffectEventMutationPhase: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -26,6 +26,7 @@ export const enableCreateEventHandleAPI = false;
26
export const enableMoveBefore = false;
27
export const enableFizzExternalRuntime = true;
28
export const enableInfiniteRenderLoopDetection = false;
29
+export const enableInfiniteRenderLoopDetectionForceThrow = false;
30
export const enableLegacyCache = false;
31
export const enableLegacyFBSupport = false;
32
export const enableLegacyHidden = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -54,6 +54,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
54
export const disableClientCache: boolean = true;
55
56
export const enableInfiniteRenderLoopDetection: boolean = false;
57
+export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
58
59
export const enableReactTestRendererWarning: boolean = false;
60
export const disableLegacyMode: boolean = true;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -27,6 +27,7 @@ export const transitionLaneExpirationMs = 5000;
27
export const enableSchedulingProfiler: boolean = __VARIANT__;
28
29
export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__;
30
+export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;
31
32
export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
33
export const enableSuspenseyImages: boolean = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -20,6 +20,7 @@ export const {
20
disableSchedulerTimeoutInWorkLoop,
21
enableEffectEventMutationPhase,
22
enableInfiniteRenderLoopDetection,
23
+ enableInfiniteRenderLoopDetectionForceThrow,
24
enableNoCloningMemoCache,
25
enableObjectFiber,
26
enableRetryLaneExpiration,
scripts/error-codes/codes.json
+2
-1
@@ -582,5 +582,6 @@
582
"594": "Cannot read Symbol exports. Only named exports are supported on a client module imported on the server.",
583
"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.",
584
"596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.",
585
- "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."
585
+ "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.",
586
+ "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."
587
}
\ No newline at end of file