[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`.
This commit is contained in:
Ruslan Lesiutin
2026-04-29 21:12:57 +01:00
committed by GitHub
parent 4c56e37e58
commit 5f7d82e8cc
11 changed files with 203 additions and 8 deletions
+151 -3
View File
@@ -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 <Child />;
}
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(<App />);
});
} 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 <Child />;
}
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(<App />));
});
} 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() {
+37 -4
View File
@@ -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, ' +
+6
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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__;
@@ -20,6 +20,7 @@ export const {
disableSchedulerTimeoutInWorkLoop,
enableEffectEventMutationPhase,
enableInfiniteRenderLoopDetection,
enableInfiniteRenderLoopDetectionForceThrow,
enableNoCloningMemoCache,
enableObjectFiber,
enableRetryLaneExpiration,
+2 -1
View File
@@ -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."
}