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.
Andrew Clark committed
Jun 27, 2023 at 13:26 UTC
822386f252fd1f0e949efa904a1ed790133329f7
4 files changed
+207
-14
packages/react-dom/src/__tests__/ReactUpdates-test.js
+58
@@ -1620,6 +1620,64 @@ describe('ReactUpdates', () => {
1620
});
1621
});
1622
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
+
1681
// TODO: Replace this branch with @gate pragmas
1682
if (__DEV__) {
1683
it('warns about a deferred infinite update loop with useEffect', async () => {
packages/react-reconciler/src/ReactFiberRootScheduler.js
+49
@@ -139,6 +139,18 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
139
}
140
}
141
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
+
154
export function flushSyncWorkOnAllRoots() {
155
// This is allowed to be called synchronously, but the caller should check
156
// the execution context first.
@@ -169,10 +181,47 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
181
182
// There may or may not be synchronous work scheduled. Let's check.
183
let didPerformSomeWork;
184
+ let nestedUpdatePasses = 0;
185
let errors: Array<mixed> | null = null;
186
isFlushingWork = true;
187
do {
188
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
+
225
let root = firstScheduledRoot;
226
while (root !== null) {
227
if (onlyLegacy && root.tag !== LegacyRoot) {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+91
-9
@@ -141,9 +141,9 @@ import {
141
includesExpiredLane,
142
getNextLanes,
143
getLanesToRetrySynchronouslyOnError,
144
- markRootUpdated,
145
- markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
146
- markRootPinged,
144
+ markRootSuspended as _markRootSuspended,
145
+ markRootUpdated as _markRootUpdated,
146
+ markRootPinged as _markRootPinged,
147
markRootEntangled,
148
markRootFinished,
149
addFiberToLanesMap,
@@ -370,6 +370,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
370
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
371
null;
372
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
+
380
// The most recent time we either committed a fallback, or when a fallback was
381
// filled in with the resolved UI. This lets us throttle the appearance of new
382
// content as it streams in, to minimize jank.
@@ -1114,6 +1121,7 @@ function finishConcurrentRender(
1121
root,
1122
workInProgressRootRecoverableErrors,
1123
workInProgressTransitions,
1124
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1125
);
1126
} else {
1127
if (
@@ -1148,6 +1156,7 @@ function finishConcurrentRender(
1156
finishedWork,
1157
workInProgressRootRecoverableErrors,
1158
workInProgressTransitions,
1159
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1160
lanes,
1161
),
1162
msUntilTimeout,
@@ -1160,6 +1169,7 @@ function finishConcurrentRender(
1169
finishedWork,
1170
workInProgressRootRecoverableErrors,
1171
workInProgressTransitions,
1172
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1173
lanes,
1174
);
1175
}
@@ -1170,6 +1180,7 @@ function commitRootWhenReady(
1180
finishedWork: Fiber,
1181
recoverableErrors: Array<CapturedValue<mixed>> | null,
1182
transitions: Array<Transition> | null,
1183
+ didIncludeRenderPhaseUpdate: boolean,
1184
lanes: Lanes,
1185
) {
1186
// TODO: Combine retry throttling with Suspensey commits. Right now they run
@@ -1196,7 +1207,13 @@ function commitRootWhenReady(
1207
// us that it's ready. This will be canceled if we start work on the
1208
// root again.
1209
root.cancelPendingCommit = schedulePendingCommit(
1199
- commitRoot.bind(null, root, recoverableErrors, transitions),
1210
+ commitRoot.bind(
1211
+ null,
1212
+ root,
1213
+ recoverableErrors,
1214
+ transitions,
1215
+ didIncludeRenderPhaseUpdate,
1216
+ ),
1217
);
1218
markRootSuspended(root, lanes);
1219
return;
@@ -1204,7 +1221,7 @@ function commitRootWhenReady(
1221
}
1222
1223
// Otherwise, commit immediately.
1207
- commitRoot(root, recoverableErrors, transitions);
1224
+ commitRoot(root, recoverableErrors, transitions, didIncludeRenderPhaseUpdate);
1225
}
1226
1227
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
@@ -1260,17 +1277,51 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
1277
return true;
1278
}
1279
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
+
1316
function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) {
1317
// When suspending, we should always exclude lanes that were pinged or (more
1318
// 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 :)
1319
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
1320
suspendedLanes = removeLanes(
1321
suspendedLanes,
1322
workInProgressRootInterleavedUpdatedLanes,
1323
);
1273
- markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes);
1324
+ _markRootSuspended(root, suspendedLanes);
1325
}
1326
1327
// This is the entry point for synchronous tasks that don't go
@@ -1341,6 +1392,7 @@ export function performSyncWorkOnRoot(root: FiberRoot): null {
1392
root,
1393
workInProgressRootRecoverableErrors,
1394
workInProgressTransitions,
1395
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1396
);
1397
1398
// Before exiting, make sure there's a callback scheduled for the next
@@ -1555,6 +1607,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1607
workInProgressRootPingedLanes = NoLanes;
1608
workInProgressRootConcurrentErrors = null;
1609
workInProgressRootRecoverableErrors = null;
1610
+ workInProgressRootDidIncludeRecursiveRenderUpdate = false;
1611
1612
finishQueueingConcurrentUpdates();
1613
@@ -2577,6 +2630,7 @@ function commitRoot(
2630
root: FiberRoot,
2631
recoverableErrors: null | Array<CapturedValue<mixed>>,
2632
transitions: Array<Transition> | null,
2633
+ didIncludeRenderPhaseUpdate: boolean,
2634
) {
2635
// TODO: This no longer makes any sense. We already wrap the mutation and
2636
// layout phases. Should be able to remove.
@@ -2590,6 +2644,7 @@ function commitRoot(
2644
root,
2645
recoverableErrors,
2646
transitions,
2647
+ didIncludeRenderPhaseUpdate,
2648
previousUpdateLanePriority,
2649
);
2650
} finally {
@@ -2604,6 +2659,7 @@ function commitRootImpl(
2659
root: FiberRoot,
2660
recoverableErrors: null | Array<CapturedValue<mixed>>,
2661
transitions: Array<Transition> | null,
2662
+ didIncludeRenderPhaseUpdate: boolean,
2663
renderPriorityLevel: EventPriority,
2664
) {
2665
do {
@@ -2683,6 +2739,9 @@ function commitRootImpl(
2739
2740
markRootFinished(root, remainingLanes);
2741
2742
+ // Reset this before firing side effects so we can detect recursive updates.
2743
+ didIncludeCommitPhaseUpdate = false;
2744
+
2745
if (root === workInProgressRoot) {
2746
// We can reset these now that they are finished.
2747
workInProgressRoot = null;
@@ -2929,7 +2988,19 @@ function commitRootImpl(
2988
2989
// Read this again, since a passive effect might have updated it
2990
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
+ ) {
3004
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
3005
markNestedUpdateScheduled();
3006
}
@@ -3471,6 +3542,17 @@ export function throwIfInfiniteUpdateLoopDetected() {
3542
rootWithNestedUpdates = null;
3543
rootWithPassiveNestedUpdates = null;
3544
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
+
3556
throw new Error(
3557
'Maximum update depth exceeded. This can happen when a component ' +
3558
'repeatedly calls setState inside componentWillUpdate or ' +
scripts/jest/matchers/toWarnDev.js
+9
-5
@@ -69,12 +69,16 @@ const createMatcherFor = (consoleMethod, matcherName) =>
69
(message.includes('\n in ') || message.includes('\n at '));
70
71
const consoleSpy = (format, ...args) => {
72
- // Ignore uncaught errors reported by jsdom
73
- // and React addendums because they're too noisy.
72
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'
82
) {
83
return;
84
}