Skip to content

Commit 822386f

Browse files
authored
Fix: Detect infinite update loops caused by render phase updates (#26625)
This PR contains a regression test and two separate fixes: a targeted fix, and a more general one that's designed as a last-resort guard against these types of bugs (both bugs in app code and bugs in React). I confirmed that each of these fixes separately are sufficient to fix the regression test I added. We can't realistically detect all infinite update loop scenarios because they could be async; even a single microtask can foil our attempts to detect a cycle. But this improves our strategy for detecting the most common kind. See commit messages for more details.
1 parent 80d9a40 commit 822386f

File tree

4 files changed

+207
-14
lines changed

4 files changed

+207
-14
lines changed

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,64 @@ describe('ReactUpdates', () => {
16201620
});
16211621
});
16221622

1623+
it("does not infinite loop if there's a synchronous render phase update on another component", () => {
1624+
let setState;
1625+
function App() {
1626+
const [, _setState] = React.useState(0);
1627+
setState = _setState;
1628+
return <Child />;
1629+
}
1630+
1631+
function Child(step) {
1632+
// This will cause an infinite update loop, and a warning in dev.
1633+
setState(n => n + 1);
1634+
return null;
1635+
}
1636+
1637+
const container = document.createElement('div');
1638+
const root = ReactDOMClient.createRoot(container);
1639+
1640+
expect(() => {
1641+
expect(() => ReactDOM.flushSync(() => root.render(<App />))).toThrow(
1642+
'Maximum update depth exceeded',
1643+
);
1644+
}).toErrorDev(
1645+
'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1646+
);
1647+
});
1648+
1649+
it("does not infinite loop if there's an async render phase update on another component", async () => {
1650+
let setState;
1651+
function App() {
1652+
const [, _setState] = React.useState(0);
1653+
setState = _setState;
1654+
return <Child />;
1655+
}
1656+
1657+
function Child(step) {
1658+
// This will cause an infinite update loop, and a warning in dev.
1659+
setState(n => n + 1);
1660+
return null;
1661+
}
1662+
1663+
const container = document.createElement('div');
1664+
const root = ReactDOMClient.createRoot(container);
1665+
1666+
await expect(async () => {
1667+
let error;
1668+
try {
1669+
await act(() => {
1670+
React.startTransition(() => root.render(<App />));
1671+
});
1672+
} catch (e) {
1673+
error = e;
1674+
}
1675+
expect(error.message).toMatch('Maximum update depth exceeded');
1676+
}).toErrorDev(
1677+
'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1678+
);
1679+
});
1680+
16231681
// TODO: Replace this branch with @gate pragmas
16241682
if (__DEV__) {
16251683
it('warns about a deferred infinite update loop with useEffect', async () => {

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
139139
}
140140
}
141141

142+
function unscheduleAllRoots() {
143+
// This is only done in a fatal error situation, as a last resort to prevent
144+
// an infinite render loop.
145+
let root = firstScheduledRoot;
146+
while (root !== null) {
147+
const next = root.next;
148+
root.next = null;
149+
root = next;
150+
}
151+
firstScheduledRoot = lastScheduledRoot = null;
152+
}
153+
142154
export function flushSyncWorkOnAllRoots() {
143155
// This is allowed to be called synchronously, but the caller should check
144156
// the execution context first.
@@ -169,10 +181,47 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
169181

170182
// There may or may not be synchronous work scheduled. Let's check.
171183
let didPerformSomeWork;
184+
let nestedUpdatePasses = 0;
172185
let errors: Array<mixed> | null = null;
173186
isFlushingWork = true;
174187
do {
175188
didPerformSomeWork = false;
189+
190+
// This outer loop re-runs if performing sync work on a root spawns
191+
// additional sync work. If it happens too many times, it's very likely
192+
// caused by some sort of infinite update loop. We already have a loop guard
193+
// in place that will trigger an error on the n+1th update, but it's
194+
// possible for that error to get swallowed if the setState is called from
195+
// an unexpected place, like during the render phase. So as an added
196+
// precaution, we also use a guard here.
197+
//
198+
// Ideally, there should be no known way to trigger this synchronous loop.
199+
// It's really just here as a safety net.
200+
//
201+
// This limit is slightly larger than the one that throws inside setState,
202+
// because that one is preferable because it includes a componens stack.
203+
if (++nestedUpdatePasses > 60) {
204+
// This is a fatal error, so we'll unschedule all the roots.
205+
unscheduleAllRoots();
206+
// TODO: Change this error message to something different to distinguish
207+
// it from the one that is thrown from setState. Those are less fatal
208+
// because they usually will result in the bad component being unmounted,
209+
// and an error boundary being triggered, rather than us having to
210+
// forcibly stop the entire scheduler.
211+
const infiniteUpdateError = new Error(
212+
'Maximum update depth exceeded. This can happen when a component ' +
213+
'repeatedly calls setState inside componentWillUpdate or ' +
214+
'componentDidUpdate. React limits the number of nested updates to ' +
215+
'prevent infinite loops.',
216+
);
217+
if (errors === null) {
218+
errors = [infiniteUpdateError];
219+
} else {
220+
errors.push(infiniteUpdateError);
221+
}
222+
break;
223+
}
224+
176225
let root = firstScheduledRoot;
177226
while (root !== null) {
178227
if (onlyLegacy && root.tag !== LegacyRoot) {

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,9 @@ import {
141141
includesExpiredLane,
142142
getNextLanes,
143143
getLanesToRetrySynchronouslyOnError,
144-
markRootUpdated,
145-
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
146-
markRootPinged,
144+
markRootSuspended as _markRootSuspended,
145+
markRootUpdated as _markRootUpdated,
146+
markRootPinged as _markRootPinged,
147147
markRootEntangled,
148148
markRootFinished,
149149
addFiberToLanesMap,
@@ -370,6 +370,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
370370
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
371371
null;
372372

373+
// Tracks when an update occurs during the render phase.
374+
let workInProgressRootDidIncludeRecursiveRenderUpdate: boolean = false;
375+
// Thacks when an update occurs during the commit phase. It's a separate
376+
// variable from the one for renders because the commit phase may run
377+
// concurrently to a render phase.
378+
let didIncludeCommitPhaseUpdate: boolean = false;
379+
373380
// The most recent time we either committed a fallback, or when a fallback was
374381
// filled in with the resolved UI. This lets us throttle the appearance of new
375382
// content as it streams in, to minimize jank.
@@ -1114,6 +1121,7 @@ function finishConcurrentRender(
11141121
root,
11151122
workInProgressRootRecoverableErrors,
11161123
workInProgressTransitions,
1124+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11171125
);
11181126
} else {
11191127
if (
@@ -1148,6 +1156,7 @@ function finishConcurrentRender(
11481156
finishedWork,
11491157
workInProgressRootRecoverableErrors,
11501158
workInProgressTransitions,
1159+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11511160
lanes,
11521161
),
11531162
msUntilTimeout,
@@ -1160,6 +1169,7 @@ function finishConcurrentRender(
11601169
finishedWork,
11611170
workInProgressRootRecoverableErrors,
11621171
workInProgressTransitions,
1172+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11631173
lanes,
11641174
);
11651175
}
@@ -1170,6 +1180,7 @@ function commitRootWhenReady(
11701180
finishedWork: Fiber,
11711181
recoverableErrors: Array<CapturedValue<mixed>> | null,
11721182
transitions: Array<Transition> | null,
1183+
didIncludeRenderPhaseUpdate: boolean,
11731184
lanes: Lanes,
11741185
) {
11751186
// TODO: Combine retry throttling with Suspensey commits. Right now they run
@@ -1196,15 +1207,21 @@ function commitRootWhenReady(
11961207
// us that it's ready. This will be canceled if we start work on the
11971208
// root again.
11981209
root.cancelPendingCommit = schedulePendingCommit(
1199-
commitRoot.bind(null, root, recoverableErrors, transitions),
1210+
commitRoot.bind(
1211+
null,
1212+
root,
1213+
recoverableErrors,
1214+
transitions,
1215+
didIncludeRenderPhaseUpdate,
1216+
),
12001217
);
12011218
markRootSuspended(root, lanes);
12021219
return;
12031220
}
12041221
}
12051222

12061223
// Otherwise, commit immediately.
1207-
commitRoot(root, recoverableErrors, transitions);
1224+
commitRoot(root, recoverableErrors, transitions, didIncludeRenderPhaseUpdate);
12081225
}
12091226

12101227
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
@@ -1260,17 +1277,51 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
12601277
return true;
12611278
}
12621279

1280+
// The extra indirections around markRootUpdated and markRootSuspended is
1281+
// needed to avoid a circular dependency between this module and
1282+
// ReactFiberLane. There's probably a better way to split up these modules and
1283+
// avoid this problem. Perhaps all the root-marking functions should move into
1284+
// the work loop.
1285+
1286+
function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
1287+
_markRootUpdated(root, updatedLanes);
1288+
1289+
// Check for recursive updates
1290+
if (executionContext & RenderContext) {
1291+
workInProgressRootDidIncludeRecursiveRenderUpdate = true;
1292+
} else if (executionContext & CommitContext) {
1293+
didIncludeCommitPhaseUpdate = true;
1294+
}
1295+
1296+
throwIfInfiniteUpdateLoopDetected();
1297+
}
1298+
1299+
function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
1300+
_markRootPinged(root, pingedLanes);
1301+
1302+
// Check for recursive pings. Pings are conceptually different from updates in
1303+
// other contexts but we call it an "update" in this context because
1304+
// repeatedly pinging a suspended render can cause a recursive render loop.
1305+
// The relevant property is that it can result in a new render attempt
1306+
// being scheduled.
1307+
if (executionContext & RenderContext) {
1308+
workInProgressRootDidIncludeRecursiveRenderUpdate = true;
1309+
} else if (executionContext & CommitContext) {
1310+
didIncludeCommitPhaseUpdate = true;
1311+
}
1312+
1313+
throwIfInfiniteUpdateLoopDetected();
1314+
}
1315+
12631316
function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) {
12641317
// When suspending, we should always exclude lanes that were pinged or (more
12651318
// rarely, since we try to avoid it) updated during the render phase.
1266-
// TODO: Lol maybe there's a better way to factor this besides this
1267-
// obnoxiously named function :)
12681319
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
12691320
suspendedLanes = removeLanes(
12701321
suspendedLanes,
12711322
workInProgressRootInterleavedUpdatedLanes,
12721323
);
1273-
markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes);
1324+
_markRootSuspended(root, suspendedLanes);
12741325
}
12751326

12761327
// This is the entry point for synchronous tasks that don't go
@@ -1341,6 +1392,7 @@ export function performSyncWorkOnRoot(root: FiberRoot): null {
13411392
root,
13421393
workInProgressRootRecoverableErrors,
13431394
workInProgressTransitions,
1395+
workInProgressRootDidIncludeRecursiveRenderUpdate,
13441396
);
13451397

13461398
// Before exiting, make sure there's a callback scheduled for the next
@@ -1555,6 +1607,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
15551607
workInProgressRootPingedLanes = NoLanes;
15561608
workInProgressRootConcurrentErrors = null;
15571609
workInProgressRootRecoverableErrors = null;
1610+
workInProgressRootDidIncludeRecursiveRenderUpdate = false;
15581611

15591612
finishQueueingConcurrentUpdates();
15601613

@@ -2577,6 +2630,7 @@ function commitRoot(
25772630
root: FiberRoot,
25782631
recoverableErrors: null | Array<CapturedValue<mixed>>,
25792632
transitions: Array<Transition> | null,
2633+
didIncludeRenderPhaseUpdate: boolean,
25802634
) {
25812635
// TODO: This no longer makes any sense. We already wrap the mutation and
25822636
// layout phases. Should be able to remove.
@@ -2590,6 +2644,7 @@ function commitRoot(
25902644
root,
25912645
recoverableErrors,
25922646
transitions,
2647+
didIncludeRenderPhaseUpdate,
25932648
previousUpdateLanePriority,
25942649
);
25952650
} finally {
@@ -2604,6 +2659,7 @@ function commitRootImpl(
26042659
root: FiberRoot,
26052660
recoverableErrors: null | Array<CapturedValue<mixed>>,
26062661
transitions: Array<Transition> | null,
2662+
didIncludeRenderPhaseUpdate: boolean,
26072663
renderPriorityLevel: EventPriority,
26082664
) {
26092665
do {
@@ -2683,6 +2739,9 @@ function commitRootImpl(
26832739

26842740
markRootFinished(root, remainingLanes);
26852741

2742+
// Reset this before firing side effects so we can detect recursive updates.
2743+
didIncludeCommitPhaseUpdate = false;
2744+
26862745
if (root === workInProgressRoot) {
26872746
// We can reset these now that they are finished.
26882747
workInProgressRoot = null;
@@ -2929,7 +2988,19 @@ function commitRootImpl(
29292988

29302989
// Read this again, since a passive effect might have updated it
29312990
remainingLanes = root.pendingLanes;
2932-
if (includesSyncLane(remainingLanes)) {
2991+
if (
2992+
// Check if there was a recursive update spawned by this render, in either
2993+
// the render phase or the commit phase. We track these explicitly because
2994+
// we can't infer from the remaining lanes alone.
2995+
didIncludeCommitPhaseUpdate ||
2996+
didIncludeRenderPhaseUpdate ||
2997+
// As an additional precaution, we also check if there's any remaining sync
2998+
// work. Theoretically this should be unreachable but if there's a mistake
2999+
// in React it helps to be overly defensive given how hard it is to debug
3000+
// those scenarios otherwise. This won't catch recursive async updates,
3001+
// though, which is why we check the flags above first.
3002+
includesSyncLane(remainingLanes)
3003+
) {
29333004
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
29343005
markNestedUpdateScheduled();
29353006
}
@@ -3471,6 +3542,17 @@ export function throwIfInfiniteUpdateLoopDetected() {
34713542
rootWithNestedUpdates = null;
34723543
rootWithPassiveNestedUpdates = null;
34733544

3545+
if (executionContext & RenderContext && workInProgressRoot !== null) {
3546+
// We're in the render phase. Disable the concurrent error recovery
3547+
// mechanism to ensure that the error we're about to throw gets handled.
3548+
// We need it to trigger the nearest error boundary so that the infinite
3549+
// update loop is broken.
3550+
workInProgressRoot.errorRecoveryDisabledLanes = mergeLanes(
3551+
workInProgressRoot.errorRecoveryDisabledLanes,
3552+
workInProgressRootRenderLanes,
3553+
);
3554+
}
3555+
34743556
throw new Error(
34753557
'Maximum update depth exceeded. This can happen when a component ' +
34763558
'repeatedly calls setState inside componentWillUpdate or ' +

scripts/jest/matchers/toWarnDev.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,16 @@ const createMatcherFor = (consoleMethod, matcherName) =>
6969
(message.includes('\n in ') || message.includes('\n at '));
7070

7171
const consoleSpy = (format, ...args) => {
72-
// Ignore uncaught errors reported by jsdom
73-
// and React addendums because they're too noisy.
7472
if (
75-
!logAllErrors &&
76-
consoleMethod === 'error' &&
77-
shouldIgnoreConsoleError(format, args)
73+
// Ignore uncaught errors reported by jsdom
74+
// and React addendums because they're too noisy.
75+
(!logAllErrors &&
76+
consoleMethod === 'error' &&
77+
shouldIgnoreConsoleError(format, args)) ||
78+
// Ignore error objects passed to console.error, which we sometimes
79+
// use as a fallback behavior, like when reportError
80+
// isn't available.
81+
typeof format !== 'string'
7882
) {
7983
return;
8084
}

0 commit comments

Comments
 (0)