From b4546cd0d4db2b913d8e7503bee86e1844073b2e Mon Sep 17 00:00:00 2001
From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
Date: Wed, 18 Mar 2026 14:11:30 +0000
Subject: [PATCH] [enableInfiniteRenderLoopDetection] Warn about potential
infinite loop, instead of interrupting (#35999)
The `enableInfiniteRenderLoopDetection` feature flag is currently
disabled everywhere. When attempted to roll out this at Meta, we've
observed multiple false-positives, where counter-based approach would
interrupt the render that would've resolved at some later iteration.
This change gates the scenarios that are only discovered with the
instrumentation behind `enableInfiniteRenderLoopDetection` flag to warn
about potential infinite loop, instead of throwing an error and hitting
an error boundary. The main reason is to see if we can a signal on which
possible area of scenarios this new approach to infinite loops covers.
The gist of the approach is to ensure that we are still throwing error
and breaking the infinite loop, if we were doing this without
`enableInfiniteRenderLoopDetection` feature flag enabled.
This will log multiple errors if there is an infinite loop, but this
should be fine, and it also aligns with the pattern for warnings about
passive effects infinite loop.
I've validated that tests in `ReactUpdates-test.js` are passing
independently whether the feature flag is enabled or not.
---
.../src/__tests__/ReactUpdates-test.js | 73 ++++++++-------
.../src/ReactFiberWorkLoop.js | 89 ++++++++++++++-----
2 files changed, 109 insertions(+), 53 deletions(-)
diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js
index 460f77a02f32..cf2e958d4511 100644
--- a/packages/react-dom/src/__tests__/ReactUpdates-test.js
+++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js
@@ -1792,8 +1792,8 @@ describe('ReactUpdates', () => {
expect(subscribers.length).toBe(limit);
});
- it("does not infinite loop if there's a synchronous render phase update on another component", async () => {
- if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
+ it("warns about potential infinite loop if there's a synchronous render phase update on another component", async () => {
+ if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
return;
}
let setState;
@@ -1809,22 +1809,29 @@ describe('ReactUpdates', () => {
return null;
}
- const container = document.createElement('div');
- const root = ReactDOMClient.createRoot(container);
-
- await expect(async () => {
- await act(() => ReactDOM.flushSync(() => root.render()));
- }).rejects.toThrow('Maximum update depth exceeded');
- assertConsoleErrorDev([
- 'Cannot update a component (`App`) while rendering a different component (`Child`). ' +
- 'To locate the bad setState() call inside `Child`, ' +
- 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' +
- ' in App (at **)',
- ]);
+ const originalConsoleError = console.error;
+ console.error = e => {
+ if (
+ typeof e === 'string' &&
+ e.startsWith(
+ 'Maximum update depth exceeded. This could be an infinite loop.',
+ )
+ ) {
+ Scheduler.log('stop');
+ }
+ };
+ try {
+ const container = document.createElement('div');
+ const root = ReactDOMClient.createRoot(container);
+ root.render();
+ await waitFor(['stop']);
+ } finally {
+ console.error = originalConsoleError;
+ }
});
- it("does not infinite loop if there's an async render phase update on another component", async () => {
- if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
+ it("warns about potential infinite loop if there's an async render phase update on another component", async () => {
+ if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
return;
}
let setState;
@@ -1840,21 +1847,25 @@ describe('ReactUpdates', () => {
return null;
}
- const container = document.createElement('div');
- const root = ReactDOMClient.createRoot(container);
-
- await expect(async () => {
- await act(() => {
- React.startTransition(() => root.render());
- });
- }).rejects.toThrow('Maximum update depth exceeded');
-
- assertConsoleErrorDev([
- 'Cannot update a component (`App`) while rendering a different component (`Child`). ' +
- 'To locate the bad setState() call inside `Child`, ' +
- 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' +
- ' in App (at **)',
- ]);
+ const originalConsoleError = console.error;
+ console.error = e => {
+ if (
+ typeof e === 'string' &&
+ e.startsWith(
+ 'Maximum update depth exceeded. This could be an infinite loop.',
+ )
+ ) {
+ Scheduler.log('stop');
+ }
+ };
+ try {
+ const container = document.createElement('div');
+ const root = ReactDOMClient.createRoot(container);
+ React.startTransition(() => root.render());
+ await waitFor(['stop']);
+ } finally {
+ console.error = originalConsoleError;
+ }
});
// TODO: Replace this branch with @gate pragmas
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index d055b271ad77..15a260bc660c 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -751,6 +751,11 @@ let rootWithNestedUpdates: FiberRoot | null = null;
let isFlushingPassiveEffects = false;
let didScheduleUpdateDuringPassiveEffects = false;
+const NO_NESTED_UPDATE = 0;
+const NESTED_UPDATE_SYNC_LANE = 1;
+const NESTED_UPDATE_PHASE_SPAWN = 2;
+let nestedUpdateKind: 0 | 1 | 2 = NO_NESTED_UPDATE;
+
const NESTED_PASSIVE_UPDATE_LIMIT = 50;
let nestedPassiveUpdateCount: number = 0;
let rootWithPassiveNestedUpdates: FiberRoot | null = null;
@@ -4313,15 +4318,30 @@ function flushSpawnedWork(): void {
// hydration lanes in this check, because render triggered by selective
// hydration is conceptually not an update.
if (
+ // Was the finished render the result of an update (not hydration)?
+ includesSomeLane(lanes, UpdateLanes) &&
+ // Did it schedule a sync update?
+ includesSomeLane(remainingLanes, SyncUpdateLanes)
+ ) {
+ if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
+ markNestedUpdateScheduled();
+ }
+
+ // Count the number of times the root synchronously re-renders without
+ // finishing. If there are too many, it indicates an infinite update loop.
+ if (root === rootWithNestedUpdates) {
+ nestedUpdateCount++;
+ } else {
+ nestedUpdateCount = 0;
+ rootWithNestedUpdates = root;
+ }
+ nestedUpdateKind = NESTED_UPDATE_SYNC_LANE;
+ } else if (
// Check if there was a recursive update spawned by this render, in either
// the render phase or the commit phase. We track these explicitly because
// we can't infer from the remaining lanes alone.
- (enableInfiniteRenderLoopDetection &&
- (didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate)) ||
- // Was the finished render the result of an update (not hydration)?
- (includesSomeLane(lanes, UpdateLanes) &&
- // Did it schedule a sync update?
- includesSomeLane(remainingLanes, SyncUpdateLanes))
+ enableInfiniteRenderLoopDetection &&
+ (didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate)
) {
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
markNestedUpdateScheduled();
@@ -4335,8 +4355,11 @@ function flushSpawnedWork(): void {
nestedUpdateCount = 0;
rootWithNestedUpdates = root;
}
+ nestedUpdateKind = NESTED_UPDATE_PHASE_SPAWN;
} else {
nestedUpdateCount = 0;
+ rootWithNestedUpdates = null;
+ nestedUpdateKind = NO_NESTED_UPDATE;
}
if (enableProfilerTimer && enableComponentPerformanceTrack) {
@@ -5152,25 +5175,47 @@ export function throwIfInfiniteUpdateLoopDetected() {
rootWithNestedUpdates = null;
rootWithPassiveNestedUpdates = null;
+ const updateKind = nestedUpdateKind;
+ nestedUpdateKind = NO_NESTED_UPDATE;
+
if (enableInfiniteRenderLoopDetection) {
- if (executionContext & RenderContext && workInProgressRoot !== null) {
- // We're in the render phase. Disable the concurrent error recovery
- // mechanism to ensure that the error we're about to throw gets handled.
- // We need it to trigger the nearest error boundary so that the infinite
- // update loop is broken.
- workInProgressRoot.errorRecoveryDisabledLanes = mergeLanes(
- workInProgressRoot.errorRecoveryDisabledLanes,
- workInProgressRootRenderLanes,
- );
+ if (updateKind === NESTED_UPDATE_SYNC_LANE) {
+ if (executionContext & RenderContext && workInProgressRoot !== null) {
+ // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
+ 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, ' +
+ 'causing infinite render loop. React limits the number of nested updates to ' +
+ 'prevent infinite loops.',
+ );
+ }
+ } else {
+ throw new Error(
+ 'Maximum update depth exceeded. This can happen when a component ' +
+ 'repeatedly calls setState inside componentWillUpdate or ' +
+ 'componentDidUpdate. React limits the number of nested updates to ' +
+ 'prevent infinite loops.',
+ );
+ }
+ } else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) {
+ 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, ' +
+ 'causing infinite render loop. React limits the number of nested updates to ' +
+ 'prevent infinite loops.',
+ );
+ }
}
+ } else {
+ throw new Error(
+ 'Maximum update depth exceeded. This can happen when a component ' +
+ 'repeatedly calls setState inside componentWillUpdate or ' +
+ 'componentDidUpdate. React limits the number of nested updates to ' +
+ 'prevent infinite loops.',
+ );
}
-
- throw new Error(
- 'Maximum update depth exceeded. This can happen when a component ' +
- 'repeatedly calls setState inside componentWillUpdate or ' +
- 'componentDidUpdate. React limits the number of nested updates to ' +
- 'prevent infinite loops.',
- );
}
if (__DEV__) {