Skip to content

Commit fef12a0

Browse files
authored
fix: explicitly warn for infinite loops discovered only via enableInfiniteRenderLoopDetection (#36195)
My change in #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`.
1 parent 705268d commit fef12a0

3 files changed

Lines changed: 119 additions & 5 deletions

File tree

packages/react-dom/src/__tests__/ReactUpdates-test.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2006,6 +2006,115 @@ describe('ReactUpdates', () => {
20062006
]);
20072007
});
20082008

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+
20092118
it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
20102119
let scheduleUpdate;
20112120
function TooManyRefUpdates() {

packages/react-reconciler/src/ReactFiberConcurrentUpdates.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null {
254254
// current behavior we've used for several release cycles. Consider not
255255
// performing this check if the updated fiber already unmounted, since it's
256256
// not possible for that to cause an infinite update loop.
257-
throwIfInfiniteUpdateLoopDetected();
257+
throwIfInfiniteUpdateLoopDetected(false);
258258

259259
// When a setState happens, we must ensure the root is scheduled. Because
260260
// update queues do not have a backpointer to the root, the only way to do

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,7 +1754,7 @@ function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
17541754
didIncludeCommitPhaseUpdate = true;
17551755
}
17561756

1757-
throwIfInfiniteUpdateLoopDetected();
1757+
throwIfInfiniteUpdateLoopDetected(true);
17581758
}
17591759
}
17601760

@@ -1773,7 +1773,7 @@ function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
17731773
didIncludeCommitPhaseUpdate = true;
17741774
}
17751775

1776-
throwIfInfiniteUpdateLoopDetected();
1776+
throwIfInfiniteUpdateLoopDetected(true);
17771777
}
17781778
}
17791779

@@ -5175,7 +5175,9 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
51755175
retryTimedOutBoundary(boundaryFiber, retryLane);
51765176
}
51775177

5178-
export function throwIfInfiniteUpdateLoopDetected() {
5178+
export function throwIfInfiniteUpdateLoopDetected(
5179+
isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
5180+
) {
51795181
if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
51805182
nestedUpdateCount = 0;
51815183
nestedPassiveUpdateCount = 0;
@@ -5187,7 +5189,10 @@ export function throwIfInfiniteUpdateLoopDetected() {
51875189

51885190
if (enableInfiniteRenderLoopDetection) {
51895191
if (updateKind === NESTED_UPDATE_SYNC_LANE) {
5190-
if (executionContext & RenderContext && workInProgressRoot !== null) {
5192+
if (
5193+
isFromInfiniteRenderLoopDetectionInstrumentation ||
5194+
(executionContext & RenderContext && workInProgressRoot !== null)
5195+
) {
51915196
// This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
51925197
if (__DEV__) {
51935198
console.error(

0 commit comments

Comments
 (0)