@samitouri / QOS-React / commits / fef12a01c8

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(