@samitouri / QOS-React / commits / f4e0d4ed0c

[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`.

Ruslan Lesiutin committed Apr 29, 2026 at 21:12 UTC f4e0d4ed0cb44f5f106579d20824f49ec41247f3
11 files changed +203 -8
packages/react-dom/src/__tests__/ReactUpdates-test.js
+151 -3
@@ -1793,7 +1793,14 @@ describe('ReactUpdates', () => {
1793 });
1794
1795 it("warns about potential infinite loop if there's a synchronous render phase update on another component", async () => {
1796 - if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1796 + if (
1797 + !__DEV__ ||
1798 + gate(
1799 + flags =>
1800 + !flags.enableInfiniteRenderLoopDetection ||
1801 + flags.enableInfiniteRenderLoopDetectionForceThrow,
1802 + )
1803 + ) {
1804 return;
1805 }
1806 let setState;
@@ -1831,7 +1838,14 @@ describe('ReactUpdates', () => {
1838 });
1839
1840 it("warns about potential infinite loop if there's an async render phase update on another component", async () => {
1834 - if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1841 + if (
1842 + !__DEV__ ||
1843 + gate(
1844 + flags =>
1845 + !flags.enableInfiniteRenderLoopDetection ||
1846 + flags.enableInfiniteRenderLoopDetectionForceThrow,
1847 + )
1848 + ) {
1849 return;
1850 }
1851 let setState;
@@ -2007,7 +2021,14 @@ describe('ReactUpdates', () => {
2021 });
2022
2023 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)) {
2024 + if (
2025 + !__DEV__ ||
2026 + gate(
2027 + flags =>
2028 + !flags.enableInfiniteRenderLoopDetection ||
2029 + flags.enableInfiniteRenderLoopDetectionForceThrow,
2030 + )
2031 + ) {
2032 return;
2033 }
2034
@@ -2115,6 +2136,133 @@ describe('ReactUpdates', () => {
2136 expect(errors).toEqual([]);
2137 });
2138
2139 + // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
2140 + it('throws when sync render-phase update loop is detected with force-throw enabled', async () => {
2141 + // Render-phase setState on another component's hook produces a sync
2142 + // recursive update. With ForceThrow enabled this should throw via
2143 + // throwForcedInfiniteRenderLoopError instead of only warning in DEV.
2144 + let setState;
2145 + let shouldStop = false;
2146 + function App() {
2147 + const [, _setState] = React.useState(0);
2148 + setState = _setState;
2149 + return <Child />;
2150 + }
2151 +
2152 + function Child() {
2153 + if (shouldStop) {
2154 + return null;
2155 + }
2156 + setState(n => n + 1);
2157 + return null;
2158 + }
2159 +
2160 + const container = document.createElement('div');
2161 + const errors = [];
2162 + const captureError = error => {
2163 + errors.push(error.message);
2164 + // Stop scheduling new updates so the test (and the gate-off variant
2165 + // where the legacy error path is recoverable) can terminate cleanly
2166 + // without tripping the babel infinite-loop guard.
2167 + shouldStop = true;
2168 + };
2169 + const root = ReactDOMClient.createRoot(container, {
2170 + onUncaughtError: captureError,
2171 + onRecoverableError: captureError,
2172 + onCaughtError: captureError,
2173 + });
2174 +
2175 + // The render-phase setState path also produces a dev-only "Cannot update
2176 + // a component while rendering a different component" console.error on
2177 + // every recursion. Swallow those so the test framework doesn't require
2178 + // us to assert each one.
2179 + const originalConsoleError = console.error;
2180 + console.error = msg => {
2181 + if (
2182 + typeof msg === 'string' &&
2183 + msg.startsWith('Cannot update a component')
2184 + ) {
2185 + return;
2186 + }
2187 + originalConsoleError(msg);
2188 + };
2189 + try {
2190 + await act(() => {
2191 + root.render(<App />);
2192 + });
2193 + } finally {
2194 + console.error = originalConsoleError;
2195 + }
2196 +
2197 + expect(errors.length).toBeGreaterThanOrEqual(1);
2198 + expect(errors[0]).toContain(
2199 + 'Maximum update depth exceeded. This could be an infinite loop.',
2200 + );
2201 + });
2202 +
2203 + // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
2204 + it('throws when phase-spawn update loop is detected with force-throw enabled', async () => {
2205 + // Wrapping the initial render in startTransition makes the render-phase
2206 + // setState inherit a non-sync transition lane. After commit, the next
2207 + // render is non-sync, so the loop detector classifies the recursion as
2208 + // NESTED_UPDATE_PHASE_SPAWN (rather than SYNC_LANE). With ForceThrow
2209 + // enabled, this branch should throw via throwForcedInfiniteRenderLoopError
2210 + // instead of only warning in DEV.
2211 + let setState;
2212 + let shouldStop = false;
2213 + // Hard cap on Child renders. Without enableInfiniteRenderLoopDetection,
2214 + // the PHASE_SPAWN branch is gated off entirely, so no throw fires and
2215 + // the loop would otherwise run until the babel infinite-loop guard.
2216 + let renderCount = 0;
2217 + const RENDER_CAP = 100;
2218 + function App() {
2219 + const [, _setState] = React.useState(0);
2220 + setState = _setState;
2221 + return <Child />;
2222 + }
2223 +
2224 + function Child() {
2225 + if (shouldStop || renderCount >= RENDER_CAP) {
2226 + return null;
2227 + }
2228 + renderCount++;
2229 + setState(n => n + 1);
2230 + return null;
2231 + }
2232 +
2233 + const container = document.createElement('div');
2234 + const errors = [];
2235 + const root = ReactDOMClient.createRoot(container, {
2236 + onUncaughtError: error => {
2237 + errors.push(error.message);
2238 + shouldStop = true;
2239 + },
2240 + });
2241 +
2242 + const originalConsoleError = console.error;
2243 + console.error = msg => {
2244 + if (
2245 + typeof msg === 'string' &&
2246 + msg.startsWith('Cannot update a component')
2247 + ) {
2248 + return;
2249 + }
2250 + originalConsoleError(msg);
2251 + };
2252 + try {
2253 + await act(() => {
2254 + React.startTransition(() => root.render(<App />));
2255 + });
2256 + } finally {
2257 + console.error = originalConsoleError;
2258 + }
2259 +
2260 + expect(errors.length).toBeGreaterThanOrEqual(1);
2261 + expect(errors[0]).toContain(
2262 + 'Maximum update depth exceeded. This could be an infinite loop.',
2263 + );
2264 + });
2265 +
2266 it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
2267 let scheduleUpdate;
2268 function TooManyRefUpdates() {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+37 -4
@@ -51,6 +51,7 @@ import {
51 disableLegacyContext,
52 alwaysThrottleRetries,
53 enableInfiniteRenderLoopDetection,
54 + enableInfiniteRenderLoopDetectionForceThrow,
55 disableLegacyMode,
56 enableComponentPerformanceTrack,
57 enableYieldingBeforePassive,
@@ -5175,6 +5176,27 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
5176 retryTimedOutBoundary(boundaryFiber, retryLane);
5177 }
5178
5179 +function throwForcedInfiniteRenderLoopError(
5180 + root: FiberRoot | null,
5181 + renderLanes: Lanes,
5182 +): empty {
5183 + if (root !== null) {
5184 + // Disable concurrent error recovery for the in-progress render so the thrown
5185 + // error reaches the nearest error boundary and breaks the infinite update
5186 + // loop instead of being silently retried by the recovery mechanism.
5187 + root.errorRecoveryDisabledLanes = mergeLanes(
5188 + root.errorRecoveryDisabledLanes,
5189 + renderLanes,
5190 + );
5191 + }
5192 + throw new Error(
5193 + 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
5194 + 'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
5195 + 'causing infinite render loop. React limits the number of nested updates to ' +
5196 + 'prevent infinite loops.',
5197 + );
5198 +}
5199 +
5200 export function throwIfInfiniteUpdateLoopDetected(
5201 isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
5202 ) {
@@ -5191,10 +5213,16 @@ export function throwIfInfiniteUpdateLoopDetected(
5213 if (updateKind === NESTED_UPDATE_SYNC_LANE) {
5214 if (
5215 isFromInfiniteRenderLoopDetectionInstrumentation ||
5194 - (executionContext & RenderContext && workInProgressRoot !== null)
5216 + (executionContext & RenderContext) !== NoContext
5217 ) {
5196 - // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
5197 - if (__DEV__) {
5218 + // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection,
5219 + // warn instead of throwing, unless enableInfiniteRenderLoopDetectionForceThrow.
5220 + if (enableInfiniteRenderLoopDetectionForceThrow) {
5221 + throwForcedInfiniteRenderLoopError(
5222 + workInProgressRoot,
5223 + workInProgressRootRenderLanes,
5224 + );
5225 + } else if (__DEV__) {
5226 console.error(
5227 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
5228 'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
@@ -5211,7 +5239,12 @@ export function throwIfInfiniteUpdateLoopDetected(
5239 );
5240 }
5241 } else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) {
5214 - if (__DEV__) {
5242 + if (enableInfiniteRenderLoopDetectionForceThrow) {
5243 + throwForcedInfiniteRenderLoopError(
5244 + workInProgressRoot,
5245 + workInProgressRootRenderLanes,
5246 + );
5247 + } else if (__DEV__) {
5248 console.error(
5249 'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
5250 'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
packages/shared/ReactFeatureFlags.js
+6
@@ -144,6 +144,12 @@ export const transitionLaneExpirationMs = 5000;
144 * by setState or similar outside of the component owning the state.
145 */
146 export const enableInfiniteRenderLoopDetection: boolean = false;
147 +/**
148 + * When `enableInfiniteRenderLoopDetection` is on, forces the detection
149 + * mechanism to throw instead of only warning in cases where it would
150 + * otherwise downgrade to a warning.
151 + */
152 +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
153
154 export const enableFragmentRefs: boolean = true;
155 export const enableFragmentRefsScrollIntoView: boolean = true;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -47,6 +47,7 @@ export const enableCreateEventHandleAPI: boolean = false;
47 export const enableMoveBefore: boolean = true;
48 export const enableFizzExternalRuntime: boolean = true;
49 export const enableInfiniteRenderLoopDetection: boolean = false;
50 +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
51 export const enableLegacyCache: boolean = false;
52 export const enableLegacyFBSupport: boolean = false;
53 export const enableLegacyHidden: boolean = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -31,6 +31,7 @@ export const enableCreateEventHandleAPI: boolean = false;
31 export const enableMoveBefore: boolean = true;
32 export const enableFizzExternalRuntime: boolean = true;
33 export const enableInfiniteRenderLoopDetection: boolean = false;
34 +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
35 export const enableLegacyCache: boolean = false;
36 export const enableLegacyFBSupport: boolean = false;
37 export const enableLegacyHidden: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -52,6 +52,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
52 export const disableClientCache: boolean = true;
53
54 export const enableInfiniteRenderLoopDetection: boolean = false;
55 +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
56
57 export const enableEagerAlternateStateNodeCleanup: boolean = true;
58 export const enableEffectEventMutationPhase: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -26,6 +26,7 @@ export const enableCreateEventHandleAPI = false;
26 export const enableMoveBefore = false;
27 export const enableFizzExternalRuntime = true;
28 export const enableInfiniteRenderLoopDetection = false;
29 +export const enableInfiniteRenderLoopDetectionForceThrow = false;
30 export const enableLegacyCache = false;
31 export const enableLegacyFBSupport = false;
32 export const enableLegacyHidden = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -54,6 +54,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
54 export const disableClientCache: boolean = true;
55
56 export const enableInfiniteRenderLoopDetection: boolean = false;
57 +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
58
59 export const enableReactTestRendererWarning: boolean = false;
60 export const disableLegacyMode: boolean = true;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -27,6 +27,7 @@ export const transitionLaneExpirationMs = 5000;
27 export const enableSchedulingProfiler: boolean = __VARIANT__;
28
29 export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__;
30 +export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;
31
32 export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
33 export const enableSuspenseyImages: boolean = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -20,6 +20,7 @@ export const {
20 disableSchedulerTimeoutInWorkLoop,
21 enableEffectEventMutationPhase,
22 enableInfiniteRenderLoopDetection,
23 + enableInfiniteRenderLoopDetectionForceThrow,
24 enableNoCloningMemoCache,
25 enableObjectFiber,
26 enableRetryLaneExpiration,
scripts/error-codes/codes.json
+2 -1
@@ -582,5 +582,6 @@
582 "594": "Cannot read Symbol exports. Only named exports are supported on a client module imported on the server.",
583 "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.",
584 "596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.",
585 - "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."
585 + "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.",
586 + "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."
587 }
\ No newline at end of file