Skip to content

Commit fb0e797

Browse files
acdlitekassens
authored andcommitted
Fix: Detect infinite update loops caused by render phase updates (facebook#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 2cd19ed commit fb0e797

File tree

4 files changed

+208
-16
lines changed

4 files changed

+208
-16
lines changed

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,64 @@ describe('ReactUpdates', () => {
17091709
expect(subscribers.length).toBe(limit);
17101710
});
17111711

1712+
it("does not infinite loop if there's a synchronous render phase update on another component", () => {
1713+
let setState;
1714+
function App() {
1715+
const [, _setState] = React.useState(0);
1716+
setState = _setState;
1717+
return <Child />;
1718+
}
1719+
1720+
function Child(step) {
1721+
// This will cause an infinite update loop, and a warning in dev.
1722+
setState(n => n + 1);
1723+
return null;
1724+
}
1725+
1726+
const container = document.createElement('div');
1727+
const root = ReactDOMClient.createRoot(container);
1728+
1729+
expect(() => {
1730+
expect(() => ReactDOM.flushSync(() => root.render(<App />))).toThrow(
1731+
'Maximum update depth exceeded',
1732+
);
1733+
}).toErrorDev(
1734+
'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1735+
);
1736+
});
1737+
1738+
it("does not infinite loop if there's an async render phase update on another component", async () => {
1739+
let setState;
1740+
function App() {
1741+
const [, _setState] = React.useState(0);
1742+
setState = _setState;
1743+
return <Child />;
1744+
}
1745+
1746+
function Child(step) {
1747+
// This will cause an infinite update loop, and a warning in dev.
1748+
setState(n => n + 1);
1749+
return null;
1750+
}
1751+
1752+
const container = document.createElement('div');
1753+
const root = ReactDOMClient.createRoot(container);
1754+
1755+
await expect(async () => {
1756+
let error;
1757+
try {
1758+
await act(() => {
1759+
React.startTransition(() => root.render(<App />));
1760+
});
1761+
} catch (e) {
1762+
error = e;
1763+
}
1764+
expect(error.message).toMatch('Maximum update depth exceeded');
1765+
}).toErrorDev(
1766+
'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1767+
);
1768+
});
1769+
17121770
// TODO: Replace this branch with @gate pragmas
17131771
if (__DEV__) {
17141772
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.
@@ -166,10 +178,47 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
166178

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

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,10 @@ import {
147147
getNextLanes,
148148
getEntangledLanes,
149149
getLanesToRetrySynchronouslyOnError,
150-
markRootUpdated,
151-
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
152-
markRootPinged,
153150
upgradePendingLanesToSync,
151+
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
152+
markRootUpdated as _markRootUpdated,
153+
markRootPinged as _markRootPinged,
154154
markRootFinished,
155155
addFiberToLanesMap,
156156
movePendingFibersToMemoized,
@@ -381,6 +381,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
381381
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
382382
null;
383383

384+
// Tracks when an update occurs during the render phase.
385+
let workInProgressRootDidIncludeRecursiveRenderUpdate: boolean = false;
386+
// Thacks when an update occurs during the commit phase. It's a separate
387+
// variable from the one for renders because the commit phase may run
388+
// concurrently to a render phase.
389+
let didIncludeCommitPhaseUpdate: boolean = false;
390+
384391
// The most recent time we either committed a fallback, or when a fallback was
385392
// filled in with the resolved UI. This lets us throttle the appearance of new
386393
// content as it streams in, to minimize jank.
@@ -1155,6 +1162,7 @@ function finishConcurrentRender(
11551162
workInProgressRootRecoverableErrors,
11561163
workInProgressTransitions,
11571164
workInProgressDeferredLane,
1165+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11581166
);
11591167
} else {
11601168
if (
@@ -1189,6 +1197,7 @@ function finishConcurrentRender(
11891197
finishedWork,
11901198
workInProgressRootRecoverableErrors,
11911199
workInProgressTransitions,
1200+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11921201
lanes,
11931202
workInProgressDeferredLane,
11941203
),
@@ -1202,6 +1211,7 @@ function finishConcurrentRender(
12021211
finishedWork,
12031212
workInProgressRootRecoverableErrors,
12041213
workInProgressTransitions,
1214+
workInProgressRootDidIncludeRecursiveRenderUpdate,
12051215
lanes,
12061216
workInProgressDeferredLane,
12071217
);
@@ -1213,6 +1223,7 @@ function commitRootWhenReady(
12131223
finishedWork: Fiber,
12141224
recoverableErrors: Array<CapturedValue<mixed>> | null,
12151225
transitions: Array<Transition> | null,
1226+
didIncludeRenderPhaseUpdate: boolean,
12161227
lanes: Lanes,
12171228
spawnedLane: Lane,
12181229
) {
@@ -1240,15 +1251,27 @@ function commitRootWhenReady(
12401251
// us that it's ready. This will be canceled if we start work on the
12411252
// root again.
12421253
root.cancelPendingCommit = schedulePendingCommit(
1243-
commitRoot.bind(null, root, recoverableErrors, transitions),
1254+
commitRoot.bind(
1255+
null,
1256+
root,
1257+
recoverableErrors,
1258+
transitions,
1259+
didIncludeRenderPhaseUpdate,
1260+
),
12441261
);
12451262
markRootSuspended(root, lanes, spawnedLane);
12461263
return;
12471264
}
12481265
}
12491266

12501267
// Otherwise, commit immediately.
1251-
commitRoot(root, recoverableErrors, transitions, spawnedLane);
1268+
commitRoot(
1269+
root,
1270+
recoverableErrors,
1271+
transitions,
1272+
spawnedLane,
1273+
didIncludeRenderPhaseUpdate,
1274+
);
12521275
}
12531276

12541277
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
@@ -1304,21 +1327,55 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
13041327
return true;
13051328
}
13061329

1330+
// The extra indirections around markRootUpdated and markRootSuspended is
1331+
// needed to avoid a circular dependency between this module and
1332+
// ReactFiberLane. There's probably a better way to split up these modules and
1333+
// avoid this problem. Perhaps all the root-marking functions should move into
1334+
// the work loop.
1335+
1336+
function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
1337+
_markRootUpdated(root, updatedLanes);
1338+
1339+
// Check for recursive updates
1340+
if (executionContext & RenderContext) {
1341+
workInProgressRootDidIncludeRecursiveRenderUpdate = true;
1342+
} else if (executionContext & CommitContext) {
1343+
didIncludeCommitPhaseUpdate = true;
1344+
}
1345+
1346+
throwIfInfiniteUpdateLoopDetected();
1347+
}
1348+
1349+
function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
1350+
_markRootPinged(root, pingedLanes);
1351+
1352+
// Check for recursive pings. Pings are conceptually different from updates in
1353+
// other contexts but we call it an "update" in this context because
1354+
// repeatedly pinging a suspended render can cause a recursive render loop.
1355+
// The relevant property is that it can result in a new render attempt
1356+
// being scheduled.
1357+
if (executionContext & RenderContext) {
1358+
workInProgressRootDidIncludeRecursiveRenderUpdate = true;
1359+
} else if (executionContext & CommitContext) {
1360+
didIncludeCommitPhaseUpdate = true;
1361+
}
1362+
1363+
throwIfInfiniteUpdateLoopDetected();
1364+
}
1365+
13071366
function markRootSuspended(
13081367
root: FiberRoot,
13091368
suspendedLanes: Lanes,
13101369
spawnedLane: Lane,
13111370
) {
13121371
// When suspending, we should always exclude lanes that were pinged or (more
13131372
// rarely, since we try to avoid it) updated during the render phase.
1314-
// TODO: Lol maybe there's a better way to factor this besides this
1315-
// obnoxiously named function :)
13161373
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
13171374
suspendedLanes = removeLanes(
13181375
suspendedLanes,
13191376
workInProgressRootInterleavedUpdatedLanes,
13201377
);
1321-
markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes, spawnedLane);
1378+
_markRootSuspended(root, suspendedLanes, spawnedLane);
13221379
}
13231380

13241381
// This is the entry point for synchronous tasks that don't go
@@ -1392,6 +1449,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
13921449
workInProgressRootRecoverableErrors,
13931450
workInProgressTransitions,
13941451
workInProgressDeferredLane,
1452+
workInProgressRootDidIncludeRecursiveRenderUpdate,
13951453
);
13961454

13971455
// Before exiting, make sure there's a callback scheduled for the next
@@ -1607,6 +1665,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
16071665
workInProgressDeferredLane = NoLane;
16081666
workInProgressRootConcurrentErrors = null;
16091667
workInProgressRootRecoverableErrors = null;
1668+
workInProgressRootDidIncludeRecursiveRenderUpdate = false;
16101669

16111670
// Get the lanes that are entangled with whatever we're about to render. We
16121671
// track these separately so we can distinguish the priority of the render
@@ -2676,6 +2735,7 @@ function commitRoot(
26762735
recoverableErrors: null | Array<CapturedValue<mixed>>,
26772736
transitions: Array<Transition> | null,
26782737
spawnedLane: Lane,
2738+
didIncludeRenderPhaseUpdate: boolean,
26792739
) {
26802740
// TODO: This no longer makes any sense. We already wrap the mutation and
26812741
// layout phases. Should be able to remove.
@@ -2689,6 +2749,7 @@ function commitRoot(
26892749
root,
26902750
recoverableErrors,
26912751
transitions,
2752+
didIncludeRenderPhaseUpdate,
26922753
previousUpdateLanePriority,
26932754
spawnedLane,
26942755
);
@@ -2704,6 +2765,7 @@ function commitRootImpl(
27042765
root: FiberRoot,
27052766
recoverableErrors: null | Array<CapturedValue<mixed>>,
27062767
transitions: Array<Transition> | null,
2768+
didIncludeRenderPhaseUpdate: boolean,
27072769
renderPriorityLevel: EventPriority,
27082770
spawnedLane: Lane,
27092771
) {
@@ -2784,6 +2846,9 @@ function commitRootImpl(
27842846

27852847
markRootFinished(root, remainingLanes, spawnedLane);
27862848

2849+
// Reset this before firing side effects so we can detect recursive updates.
2850+
didIncludeCommitPhaseUpdate = false;
2851+
27872852
if (root === workInProgressRoot) {
27882853
// We can reset these now that they are finished.
27892854
workInProgressRoot = null;
@@ -3036,10 +3101,15 @@ function commitRootImpl(
30363101
// hydration lanes in this check, because render triggered by selective
30373102
// hydration is conceptually not an update.
30383103
if (
3104+
// Check if there was a recursive update spawned by this render, in either
3105+
// the render phase or the commit phase. We track these explicitly because
3106+
// we can't infer from the remaining lanes alone.
3107+
didIncludeCommitPhaseUpdate ||
3108+
didIncludeRenderPhaseUpdate ||
30393109
// Was the finished render the result of an update (not hydration)?
3040-
includesSomeLane(lanes, UpdateLanes) &&
3041-
// Did it schedule a sync update?
3042-
includesSomeLane(remainingLanes, SyncUpdateLanes)
3110+
(includesSomeLane(lanes, UpdateLanes) &&
3111+
// Did it schedule a sync update?
3112+
includesSomeLane(remainingLanes, SyncUpdateLanes))
30433113
) {
30443114
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
30453115
markNestedUpdateScheduled();
@@ -3582,6 +3652,17 @@ export function throwIfInfiniteUpdateLoopDetected() {
35823652
rootWithNestedUpdates = null;
35833653
rootWithPassiveNestedUpdates = null;
35843654

3655+
if (executionContext & RenderContext && workInProgressRoot !== null) {
3656+
// We're in the render phase. Disable the concurrent error recovery
3657+
// mechanism to ensure that the error we're about to throw gets handled.
3658+
// We need it to trigger the nearest error boundary so that the infinite
3659+
// update loop is broken.
3660+
workInProgressRoot.errorRecoveryDisabledLanes = mergeLanes(
3661+
workInProgressRoot.errorRecoveryDisabledLanes,
3662+
workInProgressRootRenderLanes,
3663+
);
3664+
}
3665+
35853666
throw new Error(
35863667
'Maximum update depth exceeded. This can happen when a component ' +
35873668
'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)