diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index 460f77a02f32..cf2e958d4511 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -1792,8 +1792,8 @@ describe('ReactUpdates', () => { expect(subscribers.length).toBe(limit); }); - it("does not infinite loop if there's a synchronous render phase update on another component", async () => { - if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) { + it("warns about potential infinite loop if there's a synchronous render phase update on another component", async () => { + if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) { return; } let setState; @@ -1809,22 +1809,29 @@ describe('ReactUpdates', () => { return null; } - const container = document.createElement('div'); - const root = ReactDOMClient.createRoot(container); - - await expect(async () => { - await act(() => ReactDOM.flushSync(() => root.render())); - }).rejects.toThrow('Maximum update depth exceeded'); - assertConsoleErrorDev([ - 'Cannot update a component (`App`) while rendering a different component (`Child`). ' + - 'To locate the bad setState() call inside `Child`, ' + - 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' + - ' in App (at **)', - ]); + const originalConsoleError = console.error; + console.error = e => { + if ( + typeof e === 'string' && + e.startsWith( + 'Maximum update depth exceeded. This could be an infinite loop.', + ) + ) { + Scheduler.log('stop'); + } + }; + try { + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + root.render(); + await waitFor(['stop']); + } finally { + console.error = originalConsoleError; + } }); - it("does not infinite loop if there's an async render phase update on another component", async () => { - if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) { + it("warns about potential infinite loop if there's an async render phase update on another component", async () => { + if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) { return; } let setState; @@ -1840,21 +1847,25 @@ describe('ReactUpdates', () => { return null; } - const container = document.createElement('div'); - const root = ReactDOMClient.createRoot(container); - - await expect(async () => { - await act(() => { - React.startTransition(() => root.render()); - }); - }).rejects.toThrow('Maximum update depth exceeded'); - - assertConsoleErrorDev([ - 'Cannot update a component (`App`) while rendering a different component (`Child`). ' + - 'To locate the bad setState() call inside `Child`, ' + - 'follow the stack trace as described in https://react.dev/link/setstate-in-render\n' + - ' in App (at **)', - ]); + const originalConsoleError = console.error; + console.error = e => { + if ( + typeof e === 'string' && + e.startsWith( + 'Maximum update depth exceeded. This could be an infinite loop.', + ) + ) { + Scheduler.log('stop'); + } + }; + try { + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + React.startTransition(() => root.render()); + await waitFor(['stop']); + } finally { + console.error = originalConsoleError; + } }); // TODO: Replace this branch with @gate pragmas diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index d055b271ad77..15a260bc660c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -751,6 +751,11 @@ let rootWithNestedUpdates: FiberRoot | null = null; let isFlushingPassiveEffects = false; let didScheduleUpdateDuringPassiveEffects = false; +const NO_NESTED_UPDATE = 0; +const NESTED_UPDATE_SYNC_LANE = 1; +const NESTED_UPDATE_PHASE_SPAWN = 2; +let nestedUpdateKind: 0 | 1 | 2 = NO_NESTED_UPDATE; + const NESTED_PASSIVE_UPDATE_LIMIT = 50; let nestedPassiveUpdateCount: number = 0; let rootWithPassiveNestedUpdates: FiberRoot | null = null; @@ -4313,15 +4318,30 @@ function flushSpawnedWork(): void { // hydration lanes in this check, because render triggered by selective // hydration is conceptually not an update. if ( + // Was the finished render the result of an update (not hydration)? + includesSomeLane(lanes, UpdateLanes) && + // Did it schedule a sync update? + includesSomeLane(remainingLanes, SyncUpdateLanes) + ) { + if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { + markNestedUpdateScheduled(); + } + + // Count the number of times the root synchronously re-renders without + // finishing. If there are too many, it indicates an infinite update loop. + if (root === rootWithNestedUpdates) { + nestedUpdateCount++; + } else { + nestedUpdateCount = 0; + rootWithNestedUpdates = root; + } + nestedUpdateKind = NESTED_UPDATE_SYNC_LANE; + } else if ( // Check if there was a recursive update spawned by this render, in either // the render phase or the commit phase. We track these explicitly because // we can't infer from the remaining lanes alone. - (enableInfiniteRenderLoopDetection && - (didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate)) || - // Was the finished render the result of an update (not hydration)? - (includesSomeLane(lanes, UpdateLanes) && - // Did it schedule a sync update? - includesSomeLane(remainingLanes, SyncUpdateLanes)) + enableInfiniteRenderLoopDetection && + (didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate) ) { if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { markNestedUpdateScheduled(); @@ -4335,8 +4355,11 @@ function flushSpawnedWork(): void { nestedUpdateCount = 0; rootWithNestedUpdates = root; } + nestedUpdateKind = NESTED_UPDATE_PHASE_SPAWN; } else { nestedUpdateCount = 0; + rootWithNestedUpdates = null; + nestedUpdateKind = NO_NESTED_UPDATE; } if (enableProfilerTimer && enableComponentPerformanceTrack) { @@ -5152,25 +5175,47 @@ export function throwIfInfiniteUpdateLoopDetected() { rootWithNestedUpdates = null; rootWithPassiveNestedUpdates = null; + const updateKind = nestedUpdateKind; + nestedUpdateKind = NO_NESTED_UPDATE; + if (enableInfiniteRenderLoopDetection) { - if (executionContext & RenderContext && workInProgressRoot !== null) { - // We're in the render phase. Disable the concurrent error recovery - // mechanism to ensure that the error we're about to throw gets handled. - // We need it to trigger the nearest error boundary so that the infinite - // update loop is broken. - workInProgressRoot.errorRecoveryDisabledLanes = mergeLanes( - workInProgressRoot.errorRecoveryDisabledLanes, - workInProgressRootRenderLanes, - ); + if (updateKind === NESTED_UPDATE_SYNC_LANE) { + if (executionContext & RenderContext && workInProgressRoot !== null) { + // This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing. + 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, ' + + 'causing infinite render loop. React limits the number of nested updates to ' + + 'prevent infinite loops.', + ); + } + } else { + throw new Error( + 'Maximum update depth exceeded. This can happen when a component ' + + 'repeatedly calls setState inside componentWillUpdate or ' + + 'componentDidUpdate. React limits the number of nested updates to ' + + 'prevent infinite loops.', + ); + } + } else if (updateKind === NESTED_UPDATE_PHASE_SPAWN) { + 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, ' + + 'causing infinite render loop. React limits the number of nested updates to ' + + 'prevent infinite loops.', + ); + } } + } else { + throw new Error( + 'Maximum update depth exceeded. This can happen when a component ' + + 'repeatedly calls setState inside componentWillUpdate or ' + + 'componentDidUpdate. React limits the number of nested updates to ' + + 'prevent infinite loops.', + ); } - - throw new Error( - 'Maximum update depth exceeded. This can happen when a component ' + - 'repeatedly calls setState inside componentWillUpdate or ' + - 'componentDidUpdate. React limits the number of nested updates to ' + - 'prevent infinite loops.', - ); } if (__DEV__) {