fix: explicitly warn for infinite loops discovered only via enableInfiniteRenderLoopDetection (#36195)
My change in https://github.com/facebook/react/pull/35999 did not cover all possible scenarios for emitting a warning, instead of throwing. The instrumentation not only enables the identification for the infinite loop via execution context checks, but also adds the check to more lifecycle methods, like `markRootPinged` and `markRootUpdated`. See the newly added test to understand a potential scenario. Before the fix, the error would be thrown: <img width="1192" height="424" alt="Screenshot 2026-04-08 at 17 21 51" src="https://github.com/user-attachments/assets/ba8ea379-0271-4938-ae45-e37ee75e1963" /> With the current changes, the warning is logged with `console.error`.
Ruslan Lesiutin committed
Apr 13, 2026 at 17:28 UTC
fef12a01c826ce5b8458e82240c659bf51108a46
3 files changed
+119
-5
packages/react-dom/src/__tests__/ReactUpdates-test.js
+109
@@ -2006,6 +2006,115 @@ describe('ReactUpdates', () => {
2006
]);
2007
});
2008
2009
+ 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)) {
2011
+ return;
2012
+ }
2013
+
2014
+ // When a Suspense child throws a thenable, React registers two listeners:
2015
+ // 1. ping (attachPingListener, render) → pingSuspendedRoot → markRootPinged
2016
+ // 2. retry (attachSuspenseRetryListeners, commit) → resolveRetryWakeable
2017
+ //
2018
+ // The ping path calls throwIfInfiniteUpdateLoopDetected(true) via
2019
+ // markRootPinged WITHOUT a prior getRootForUpdatedFiber(false) check.
2020
+ // When this fires during CommitContext (not RenderContext),
2021
+ // the isFromInfiniteRenderLoopDetectionInstrumentation=true parameter
2022
+ // ensures we warn instead of throw.
2023
+ //
2024
+ // Without the fix (passing false), the condition
2025
+ // false || (executionContext & RenderContext && ...)
2026
+ // evaluates to false in CommitContext, causing a throw.
2027
+ let currentResolve = null;
2028
+ let shouldStop = false;
2029
+
2030
+ function App() {
2031
+ const [, setState] = React.useState(0);
2032
+
2033
+ React.useLayoutEffect(() => {
2034
+ if (shouldStop) {
2035
+ return;
2036
+ }
2037
+ // Resolve the suspended thenable during commit phase (CommitContext).
2038
+ // The ping callback (registered first during render) fires first,
2039
+ // triggering markRootPinged → throwIfInfiniteUpdateLoopDetected(true).
2040
+ if (currentResolve !== null) {
2041
+ const resolve = currentResolve;
2042
+ currentResolve = null;
2043
+ resolve();
2044
+ }
2045
+ // Schedule a sync update to ensure nestedUpdateKind is
2046
+ // NESTED_UPDATE_SYNC_LANE at commitRootImpl epilogue.
2047
+ setState(n => n + 1);
2048
+ });
2049
+
2050
+ return (
2051
+ <React.Suspense fallback="loading">
2052
+ <SuspendingChild />
2053
+ </React.Suspense>
2054
+ );
2055
+ }
2056
+
2057
+ function SuspendingChild() {
2058
+ if (shouldStop) {
2059
+ return null;
2060
+ }
2061
+ // Each render throws a new thenable. React calls .then() on it twice
2062
+ // (ping during render, retry during commit). We collect all callbacks
2063
+ // so resolve() fires them in registration order: ping first.
2064
+ const callbacks = [];
2065
+ const thenable = {
2066
+ then(onFulfilled) {
2067
+ callbacks.push(onFulfilled);
2068
+ currentResolve = () => {
2069
+ for (let i = 0; i < callbacks.length; i++) {
2070
+ callbacks[i]();
2071
+ }
2072
+ };
2073
+ },
2074
+ };
2075
+
2076
+ throw thenable;
2077
+ }
2078
+
2079
+ const container = document.createElement('div');
2080
+ const errors = [];
2081
+ const root = ReactDOMClient.createRoot(container, {
2082
+ onUncaughtError: error => {
2083
+ errors.push(error.message);
2084
+ },
2085
+ });
2086
+
2087
+ const originalConsoleError = console.error;
2088
+ console.error = e => {
2089
+ if (
2090
+ typeof e === 'string' &&
2091
+ e.startsWith(
2092
+ 'Maximum update depth exceeded. This could be an infinite loop.',
2093
+ )
2094
+ ) {
2095
+ // Stop the loop after the first warning so act() can finish.
2096
+ shouldStop = true;
2097
+ }
2098
+ };
2099
+
2100
+ try {
2101
+ await act(() => {
2102
+ root.render(<App />);
2103
+ });
2104
+ } finally {
2105
+ console.error = originalConsoleError;
2106
+ }
2107
+
2108
+ // With the fix (throwIfInfiniteUpdateLoopDetected(true) in markRootPinged):
2109
+ // the loop is discovered via enableInfiniteRenderLoopDetection instrumentation
2110
+ // and produces a warning.
2111
+ // Without the fix (throwIfInfiniteUpdateLoopDetected(false)):
2112
+ // the same check throws because executionContext is CommitContext, not
2113
+ // RenderContext.
2114
+ expect(shouldStop).toBe(true);
2115
+ expect(errors).toEqual([]);
2116
+ });
2117
+
2118
it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
2119
let scheduleUpdate;
2120
function TooManyRefUpdates() {
packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
+1
-1
@@ -254,7 +254,7 @@ function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null {
254
// current behavior we've used for several release cycles. Consider not
255
// performing this check if the updated fiber already unmounted, since it's
256
// not possible for that to cause an infinite update loop.
257
- throwIfInfiniteUpdateLoopDetected();
257
+ throwIfInfiniteUpdateLoopDetected(false);
258
259
// When a setState happens, we must ensure the root is scheduled. Because
260
// update queues do not have a backpointer to the root, the only way to do
packages/react-reconciler/src/ReactFiberWorkLoop.js
+9
-4
@@ -1754,7 +1754,7 @@ function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
1754
didIncludeCommitPhaseUpdate = true;
1755
}
1756
1757
- throwIfInfiniteUpdateLoopDetected();
1757
+ throwIfInfiniteUpdateLoopDetected(true);
1758
}
1759
}
1760
@@ -1773,7 +1773,7 @@ function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
1773
didIncludeCommitPhaseUpdate = true;
1774
}
1775
1776
- throwIfInfiniteUpdateLoopDetected();
1776
+ throwIfInfiniteUpdateLoopDetected(true);
1777
}
1778
}
1779
@@ -5175,7 +5175,9 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
5175
retryTimedOutBoundary(boundaryFiber, retryLane);
5176
}
5177
5178
-export function throwIfInfiniteUpdateLoopDetected() {
5178
+export function throwIfInfiniteUpdateLoopDetected(
5179
+ isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
5180
+) {
5181
if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
5182
nestedUpdateCount = 0;
5183
nestedPassiveUpdateCount = 0;
@@ -5187,7 +5189,10 @@ export function throwIfInfiniteUpdateLoopDetected() {
5189
5190
if (enableInfiniteRenderLoopDetection) {
5191
if (updateKind === NESTED_UPDATE_SYNC_LANE) {
5190
- if (executionContext & RenderContext && workInProgressRoot !== null) {
5192
+ if (
5193
+ isFromInfiniteRenderLoopDetectionInstrumentation ||
5194
+ (executionContext & RenderContext && workInProgressRoot !== null)
5195
+ ) {
5196
// This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
5197
if (__DEV__) {
5198
console.error(