Skip to content

Commit f4e0d4e

Browse files
authored
[enableInfiniteRenderLoopDetection] Add a flag to force throwing (facebook#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`.
1 parent ad5dfc8 commit f4e0d4e

11 files changed

Lines changed: 203 additions & 8 deletions

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1793,7 +1793,14 @@ describe('ReactUpdates', () => {
17931793
});
17941794

17951795
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+
) {
17971804
return;
17981805
}
17991806
let setState;
@@ -1831,7 +1838,14 @@ describe('ReactUpdates', () => {
18311838
});
18321839

18331840
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+
) {
18351849
return;
18361850
}
18371851
let setState;
@@ -2007,7 +2021,14 @@ describe('ReactUpdates', () => {
20072021
});
20082022

20092023
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+
) {
20112032
return;
20122033
}
20132034

@@ -2115,6 +2136,133 @@ describe('ReactUpdates', () => {
21152136
expect(errors).toEqual([]);
21162137
});
21172138

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

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
disableLegacyContext,
5252
alwaysThrottleRetries,
5353
enableInfiniteRenderLoopDetection,
54+
enableInfiniteRenderLoopDetectionForceThrow,
5455
disableLegacyMode,
5556
enableComponentPerformanceTrack,
5657
enableYieldingBeforePassive,
@@ -5175,6 +5176,27 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
51755176
retryTimedOutBoundary(boundaryFiber, retryLane);
51765177
}
51775178

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+
51785200
export function throwIfInfiniteUpdateLoopDetected(
51795201
isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
51805202
) {
@@ -5191,10 +5213,16 @@ export function throwIfInfiniteUpdateLoopDetected(
51915213
if (updateKind === NESTED_UPDATE_SYNC_LANE) {
51925214
if (
51935215
isFromInfiniteRenderLoopDetectionInstrumentation ||
5194-
(executionContext & RenderContext && workInProgressRoot !== null)
5216+
(executionContext & RenderContext) !== NoContext
51955217
) {
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__) {
51985226
console.error(
51995227
'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
52005228
'repeatedly calls setState during render phase or inside useLayoutEffect, ' +
@@ -5211,7 +5239,12 @@ export function throwIfInfiniteUpdateLoopDetected(
52115239
);
52125240
}
52135241
} else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) {
5214-
if (__DEV__) {
5242+
if (enableInfiniteRenderLoopDetectionForceThrow) {
5243+
throwForcedInfiniteRenderLoopError(
5244+
workInProgressRoot,
5245+
workInProgressRootRenderLanes,
5246+
);
5247+
} else if (__DEV__) {
52155248
console.error(
52165249
'Maximum update depth exceeded. This could be an infinite loop. This can happen when a component ' +
52175250
'repeatedly calls setState during render phase or inside useLayoutEffect, ' +

packages/shared/ReactFeatureFlags.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ export const transitionLaneExpirationMs = 5000;
144144
* by setState or similar outside of the component owning the state.
145145
*/
146146
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;
147153

148154
export const enableFragmentRefs: boolean = true;
149155
export const enableFragmentRefsScrollIntoView: boolean = true;

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const enableCreateEventHandleAPI: boolean = false;
4747
export const enableMoveBefore: boolean = true;
4848
export const enableFizzExternalRuntime: boolean = true;
4949
export const enableInfiniteRenderLoopDetection: boolean = false;
50+
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
5051
export const enableLegacyCache: boolean = false;
5152
export const enableLegacyFBSupport: boolean = false;
5253
export const enableLegacyHidden: boolean = false;

packages/shared/forks/ReactFeatureFlags.native-oss.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const enableCreateEventHandleAPI: boolean = false;
3131
export const enableMoveBefore: boolean = true;
3232
export const enableFizzExternalRuntime: boolean = true;
3333
export const enableInfiniteRenderLoopDetection: boolean = false;
34+
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
3435
export const enableLegacyCache: boolean = false;
3536
export const enableLegacyFBSupport: boolean = false;
3637
export const enableLegacyHidden: boolean = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
5252
export const disableClientCache: boolean = true;
5353

5454
export const enableInfiniteRenderLoopDetection: boolean = false;
55+
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
5556

5657
export const enableEagerAlternateStateNodeCleanup: boolean = true;
5758
export const enableEffectEventMutationPhase: boolean = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const enableCreateEventHandleAPI = false;
2626
export const enableMoveBefore = false;
2727
export const enableFizzExternalRuntime = true;
2828
export const enableInfiniteRenderLoopDetection = false;
29+
export const enableInfiniteRenderLoopDetectionForceThrow = false;
2930
export const enableLegacyCache = false;
3031
export const enableLegacyFBSupport = false;
3132
export const enableLegacyHidden = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
5454
export const disableClientCache: boolean = true;
5555

5656
export const enableInfiniteRenderLoopDetection: boolean = false;
57+
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
5758

5859
export const enableReactTestRendererWarning: boolean = false;
5960
export const disableLegacyMode: boolean = true;

packages/shared/forks/ReactFeatureFlags.www-dynamic.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const transitionLaneExpirationMs = 5000;
2727
export const enableSchedulingProfiler: boolean = __VARIANT__;
2828

2929
export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__;
30+
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;
3031

3132
export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
3233
export const enableSuspenseyImages: boolean = __VARIANT__;

packages/shared/forks/ReactFeatureFlags.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const {
2020
disableSchedulerTimeoutInWorkLoop,
2121
enableEffectEventMutationPhase,
2222
enableInfiniteRenderLoopDetection,
23+
enableInfiniteRenderLoopDetectionForceThrow,
2324
enableNoCloningMemoCache,
2425
enableObjectFiber,
2526
enableRetryLaneExpiration,

0 commit comments

Comments
 (0)