Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 151 additions & 3 deletions packages/react-dom/src/__tests__/ReactUpdates-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1793,7 +1793,14 @@ describe('ReactUpdates', () => {
});

it("warns about potential infinite loop if there's a synchronous render phase update on another component", async () => {
if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
if (
!__DEV__ ||
gate(
flags =>
!flags.enableInfiniteRenderLoopDetection ||
flags.enableInfiniteRenderLoopDetectionForceThrow,
)
) {
return;
}
let setState;
Expand Down Expand Up @@ -1831,7 +1838,14 @@ describe('ReactUpdates', () => {
});

it("warns about potential infinite loop if there's an async render phase update on another component", async () => {
if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
if (
!__DEV__ ||
gate(
flags =>
!flags.enableInfiniteRenderLoopDetection ||
flags.enableInfiniteRenderLoopDetectionForceThrow,
)
) {
return;
}
let setState;
Expand Down Expand Up @@ -2007,7 +2021,14 @@ describe('ReactUpdates', () => {
});

it('warns instead of throwing when infinite Suspense ping loop is detected via enableInfiniteRenderLoopDetection during commit phase', async () => {
if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
if (
!__DEV__ ||
gate(
flags =>
!flags.enableInfiniteRenderLoopDetection ||
flags.enableInfiniteRenderLoopDetectionForceThrow,
)
) {
return;
}

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

// @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
it('throws when sync render-phase update loop is detected with force-throw enabled', async () => {
// Render-phase setState on another component's hook produces a sync
// recursive update. With ForceThrow enabled this should throw via
// throwForcedInfiniteRenderLoopError instead of only warning in DEV.
let setState;
let shouldStop = false;
function App() {
const [, _setState] = React.useState(0);
setState = _setState;
return <Child />;
}

function Child() {
if (shouldStop) {
return null;
}
setState(n => n + 1);
return null;
}

const container = document.createElement('div');
const errors = [];
const captureError = error => {
errors.push(error.message);
// Stop scheduling new updates so the test (and the gate-off variant
// where the legacy error path is recoverable) can terminate cleanly
// without tripping the babel infinite-loop guard.
shouldStop = true;
};
const root = ReactDOMClient.createRoot(container, {
onUncaughtError: captureError,
onRecoverableError: captureError,
onCaughtError: captureError,
});

// The render-phase setState path also produces a dev-only "Cannot update
// a component while rendering a different component" console.error on
// every recursion. Swallow those so the test framework doesn't require
// us to assert each one.
const originalConsoleError = console.error;
console.error = msg => {
if (
typeof msg === 'string' &&
msg.startsWith('Cannot update a component')
) {
return;
}
originalConsoleError(msg);
};
try {
await act(() => {
root.render(<App />);
});
} finally {
console.error = originalConsoleError;
}

expect(errors.length).toBeGreaterThanOrEqual(1);
expect(errors[0]).toContain(
'Maximum update depth exceeded. This could be an infinite loop.',
);
});

// @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
it('throws when phase-spawn update loop is detected with force-throw enabled', async () => {
// Wrapping the initial render in startTransition makes the render-phase
// setState inherit a non-sync transition lane. After commit, the next
// render is non-sync, so the loop detector classifies the recursion as
// NESTED_UPDATE_PHASE_SPAWN (rather than SYNC_LANE). With ForceThrow
// enabled, this branch should throw via throwForcedInfiniteRenderLoopError
// instead of only warning in DEV.
let setState;
let shouldStop = false;
// Hard cap on Child renders. Without enableInfiniteRenderLoopDetection,
// the PHASE_SPAWN branch is gated off entirely, so no throw fires and
// the loop would otherwise run until the babel infinite-loop guard.
let renderCount = 0;
const RENDER_CAP = 100;
function App() {
const [, _setState] = React.useState(0);
setState = _setState;
return <Child />;
}

function Child() {
if (shouldStop || renderCount >= RENDER_CAP) {
return null;
}
renderCount++;
setState(n => n + 1);
return null;
}

const container = document.createElement('div');
const errors = [];
const root = ReactDOMClient.createRoot(container, {
onUncaughtError: error => {
errors.push(error.message);
shouldStop = true;
},
});

const originalConsoleError = console.error;
console.error = msg => {
if (
typeof msg === 'string' &&
msg.startsWith('Cannot update a component')
) {
return;
}
originalConsoleError(msg);
};
try {
await act(() => {
React.startTransition(() => root.render(<App />));
});
} finally {
console.error = originalConsoleError;
}

expect(errors.length).toBeGreaterThanOrEqual(1);
expect(errors[0]).toContain(
'Maximum update depth exceeded. This could be an infinite loop.',
);
});

it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
let scheduleUpdate;
function TooManyRefUpdates() {
Expand Down
41 changes: 37 additions & 4 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
disableLegacyContext,
alwaysThrottleRetries,
enableInfiniteRenderLoopDetection,
enableInfiniteRenderLoopDetectionForceThrow,
disableLegacyMode,
enableComponentPerformanceTrack,
enableYieldingBeforePassive,
Expand Down Expand Up @@ -5175,6 +5176,27 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
retryTimedOutBoundary(boundaryFiber, retryLane);
}

function throwForcedInfiniteRenderLoopError(
root: FiberRoot | null,
renderLanes: Lanes,
): empty {
if (root !== null) {
// Disable concurrent error recovery for the in-progress render so the thrown
// error reaches the nearest error boundary and breaks the infinite update
// loop instead of being silently retried by the recovery mechanism.
root.errorRecoveryDisabledLanes = mergeLanes(
root.errorRecoveryDisabledLanes,
renderLanes,
);
}
throw new Error(
'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.',
);
}

export function throwIfInfiniteUpdateLoopDetected(
isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
) {
Expand All @@ -5191,10 +5213,16 @@ export function throwIfInfiniteUpdateLoopDetected(
if (updateKind === NESTED_UPDATE_SYNC_LANE) {
if (
isFromInfiniteRenderLoopDetectionInstrumentation ||
(executionContext & RenderContext && workInProgressRoot !== null)
(executionContext & RenderContext) !== NoContext
) {
// This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
if (__DEV__) {
// This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection,
// warn instead of throwing, unless enableInfiniteRenderLoopDetectionForceThrow.
if (enableInfiniteRenderLoopDetectionForceThrow) {
throwForcedInfiniteRenderLoopError(
workInProgressRoot,
workInProgressRootRenderLanes,
);
} else if (__DEV__) {
console.error(
'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, ' +
Expand All @@ -5211,7 +5239,12 @@ export function throwIfInfiniteUpdateLoopDetected(
);
}
} else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) {
if (__DEV__) {
if (enableInfiniteRenderLoopDetectionForceThrow) {
throwForcedInfiniteRenderLoopError(
workInProgressRoot,
workInProgressRootRenderLanes,
);
} else if (__DEV__) {
console.error(
'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, ' +
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/ReactFeatureFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ export const transitionLaneExpirationMs = 5000;
* by setState or similar outside of the component owning the state.
*/
export const enableInfiniteRenderLoopDetection: boolean = false;
/**
* When `enableInfiniteRenderLoopDetection` is on, forces the detection
* mechanism to throw instead of only warning in cases where it would
* otherwise downgrade to a warning.
*/
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;

export const enableFragmentRefs: boolean = true;
export const enableFragmentRefsScrollIntoView: boolean = true;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.native-fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const enableCreateEventHandleAPI: boolean = false;
export const enableMoveBefore: boolean = true;
export const enableFizzExternalRuntime: boolean = true;
export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableLegacyCache: boolean = false;
export const enableLegacyFBSupport: boolean = false;
export const enableLegacyHidden: boolean = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.native-oss.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const enableCreateEventHandleAPI: boolean = false;
export const enableMoveBefore: boolean = true;
export const enableFizzExternalRuntime: boolean = true;
export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableLegacyCache: boolean = false;
export const enableLegacyFBSupport: boolean = false;
export const enableLegacyHidden: boolean = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;

export const enableEagerAlternateStateNodeCleanup: boolean = true;
export const enableEffectEventMutationPhase: boolean = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const enableCreateEventHandleAPI = false;
export const enableMoveBefore = false;
export const enableFizzExternalRuntime = true;
export const enableInfiniteRenderLoopDetection = false;
export const enableInfiniteRenderLoopDetectionForceThrow = false;
export const enableLegacyCache = false;
export const enableLegacyFBSupport = false;
export const enableLegacyHidden = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const passChildrenWhenCloningPersistedNodes: boolean = false;
export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;

export const enableReactTestRendererWarning: boolean = false;
export const disableLegacyMode: boolean = true;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.www-dynamic.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const transitionLaneExpirationMs = 5000;
export const enableSchedulingProfiler: boolean = __VARIANT__;

export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;

export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
export const enableSuspenseyImages: boolean = __VARIANT__;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.www.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const {
disableSchedulerTimeoutInWorkLoop,
enableEffectEventMutationPhase,
enableInfiniteRenderLoopDetection,
enableInfiniteRenderLoopDetectionForceThrow,
enableNoCloningMemoCache,
enableObjectFiber,
enableRetryLaneExpiration,
Expand Down
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -582,5 +582,6 @@
"594": "Cannot read Symbol exports. Only named exports are supported on a client module imported on the server.",
"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.",
"596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.",
"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."
"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.",
"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."
}
Loading