Add infinite update loop detection (#28279)
This is a partial redo of https://github.com/facebook/react/pull/26625. Since that was unlanded due to some detected breakages. This now includes a feature flag to be careful in rolling this out.
Jan Kassens committed
Feb 9, 2024 at 11:14 UTC
d8c1fa6b0b8da0512cb5acab9cd4f242451392f3
10 files changed
+177
-11
packages/react-dom/src/__tests__/ReactUpdates-test.js
+64
@@ -1709,6 +1709,70 @@ describe('ReactUpdates', () => {
1709
expect(subscribers.length).toBe(limit);
1710
});
1711
1712
+ it("does not infinite loop if there's a synchronous render phase update on another component", () => {
1713
+ if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1714
+ return;
1715
+ }
1716
+ let setState;
1717
+ function App() {
1718
+ const [, _setState] = React.useState(0);
1719
+ setState = _setState;
1720
+ return <Child />;
1721
+ }
1722
+
1723
+ function Child(step) {
1724
+ // This will cause an infinite update loop, and a warning in dev.
1725
+ setState(n => n + 1);
1726
+ return null;
1727
+ }
1728
+
1729
+ const container = document.createElement('div');
1730
+ const root = ReactDOMClient.createRoot(container);
1731
+
1732
+ expect(() => {
1733
+ expect(() => ReactDOM.flushSync(() => root.render(<App />))).toThrow(
1734
+ 'Maximum update depth exceeded',
1735
+ );
1736
+ }).toErrorDev(
1737
+ 'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1738
+ );
1739
+ });
1740
+
1741
+ it("does not infinite loop if there's an async render phase update on another component", async () => {
1742
+ if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1743
+ return;
1744
+ }
1745
+ let setState;
1746
+ function App() {
1747
+ const [, _setState] = React.useState(0);
1748
+ setState = _setState;
1749
+ return <Child />;
1750
+ }
1751
+
1752
+ function Child(step) {
1753
+ // This will cause an infinite update loop, and a warning in dev.
1754
+ setState(n => n + 1);
1755
+ return null;
1756
+ }
1757
+
1758
+ const container = document.createElement('div');
1759
+ const root = ReactDOMClient.createRoot(container);
1760
+
1761
+ await expect(async () => {
1762
+ let error;
1763
+ try {
1764
+ await act(() => {
1765
+ React.startTransition(() => root.render(<App />));
1766
+ });
1767
+ } catch (e) {
1768
+ error = e;
1769
+ }
1770
+ expect(error.message).toMatch('Maximum update depth exceeded');
1771
+ }).toErrorDev(
1772
+ 'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1773
+ );
1774
+ });
1775
+
1776
// TODO: Replace this branch with @gate pragmas
1777
if (__DEV__) {
1778
it('warns about a deferred infinite update loop with useEffect', async () => {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+99
-11
@@ -40,6 +40,7 @@ import {
40
useModernStrictMode,
41
disableLegacyContext,
42
alwaysThrottleRetries,
43
+ enableInfiniteRenderLoopDetection,
44
} from 'shared/ReactFeatureFlags';
45
import ReactSharedInternals from 'shared/ReactSharedInternals';
46
import is from 'shared/objectIs';
@@ -147,10 +148,10 @@ import {
148
getNextLanes,
149
getEntangledLanes,
150
getLanesToRetrySynchronouslyOnError,
150
- markRootUpdated,
151
- markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
152
- markRootPinged,
151
upgradePendingLanesToSync,
152
+ markRootSuspended as _markRootSuspended,
153
+ markRootUpdated as _markRootUpdated,
154
+ markRootPinged as _markRootPinged,
155
markRootFinished,
156
addFiberToLanesMap,
157
movePendingFibersToMemoized,
@@ -381,6 +382,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
382
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
383
null;
384
385
+// Tracks when an update occurs during the render phase.
386
+let workInProgressRootDidIncludeRecursiveRenderUpdate: boolean = false;
387
+// Thacks when an update occurs during the commit phase. It's a separate
388
+// variable from the one for renders because the commit phase may run
389
+// concurrently to a render phase.
390
+let didIncludeCommitPhaseUpdate: boolean = false;
391
+
392
// The most recent time we either committed a fallback, or when a fallback was
393
// filled in with the resolved UI. This lets us throttle the appearance of new
394
// content as it streams in, to minimize jank.
@@ -1154,6 +1162,7 @@ function finishConcurrentRender(
1162
root,
1163
workInProgressRootRecoverableErrors,
1164
workInProgressTransitions,
1165
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1166
workInProgressDeferredLane,
1167
);
1168
} else {
@@ -1189,6 +1198,7 @@ function finishConcurrentRender(
1198
finishedWork,
1199
workInProgressRootRecoverableErrors,
1200
workInProgressTransitions,
1201
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1202
lanes,
1203
workInProgressDeferredLane,
1204
),
@@ -1202,6 +1212,7 @@ function finishConcurrentRender(
1212
finishedWork,
1213
workInProgressRootRecoverableErrors,
1214
workInProgressTransitions,
1215
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1216
lanes,
1217
workInProgressDeferredLane,
1218
);
@@ -1213,6 +1224,7 @@ function commitRootWhenReady(
1224
finishedWork: Fiber,
1225
recoverableErrors: Array<CapturedValue<mixed>> | null,
1226
transitions: Array<Transition> | null,
1227
+ didIncludeRenderPhaseUpdate: boolean,
1228
lanes: Lanes,
1229
spawnedLane: Lane,
1230
) {
@@ -1240,7 +1252,13 @@ function commitRootWhenReady(
1252
// us that it's ready. This will be canceled if we start work on the
1253
// root again.
1254
root.cancelPendingCommit = schedulePendingCommit(
1243
- commitRoot.bind(null, root, recoverableErrors, transitions),
1255
+ commitRoot.bind(
1256
+ null,
1257
+ root,
1258
+ recoverableErrors,
1259
+ transitions,
1260
+ didIncludeRenderPhaseUpdate,
1261
+ ),
1262
);
1263
markRootSuspended(root, lanes, spawnedLane);
1264
return;
@@ -1248,7 +1266,13 @@ function commitRootWhenReady(
1266
}
1267
1268
// Otherwise, commit immediately.
1251
- commitRoot(root, recoverableErrors, transitions, spawnedLane);
1269
+ commitRoot(
1270
+ root,
1271
+ recoverableErrors,
1272
+ transitions,
1273
+ didIncludeRenderPhaseUpdate,
1274
+ spawnedLane,
1275
+ );
1276
}
1277
1278
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
@@ -1304,6 +1328,46 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
1328
return true;
1329
}
1330
1331
+// The extra indirections around markRootUpdated and markRootSuspended is
1332
+// needed to avoid a circular dependency between this module and
1333
+// ReactFiberLane. There's probably a better way to split up these modules and
1334
+// avoid this problem. Perhaps all the root-marking functions should move into
1335
+// the work loop.
1336
+
1337
+function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
1338
+ _markRootUpdated(root, updatedLanes);
1339
+
1340
+ if (enableInfiniteRenderLoopDetection) {
1341
+ // Check for recursive updates
1342
+ if (executionContext & RenderContext) {
1343
+ workInProgressRootDidIncludeRecursiveRenderUpdate = true;
1344
+ } else if (executionContext & CommitContext) {
1345
+ didIncludeCommitPhaseUpdate = true;
1346
+ }
1347
+
1348
+ throwIfInfiniteUpdateLoopDetected();
1349
+ }
1350
+}
1351
+
1352
+function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
1353
+ _markRootPinged(root, pingedLanes);
1354
+
1355
+ if (enableInfiniteRenderLoopDetection) {
1356
+ // Check for recursive pings. Pings are conceptually different from updates in
1357
+ // other contexts but we call it an "update" in this context because
1358
+ // repeatedly pinging a suspended render can cause a recursive render loop.
1359
+ // The relevant property is that it can result in a new render attempt
1360
+ // being scheduled.
1361
+ if (executionContext & RenderContext) {
1362
+ workInProgressRootDidIncludeRecursiveRenderUpdate = true;
1363
+ } else if (executionContext & CommitContext) {
1364
+ didIncludeCommitPhaseUpdate = true;
1365
+ }
1366
+
1367
+ throwIfInfiniteUpdateLoopDetected();
1368
+ }
1369
+}
1370
+
1371
function markRootSuspended(
1372
root: FiberRoot,
1373
suspendedLanes: Lanes,
@@ -1311,14 +1375,12 @@ function markRootSuspended(
1375
) {
1376
// When suspending, we should always exclude lanes that were pinged or (more
1377
// 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 :)
1378
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
1379
suspendedLanes = removeLanes(
1380
suspendedLanes,
1381
workInProgressRootInterleavedUpdatedLanes,
1382
);
1321
- markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes, spawnedLane);
1383
+ _markRootSuspended(root, suspendedLanes, spawnedLane);
1384
}
1385
1386
// This is the entry point for synchronous tasks that don't go
@@ -1391,6 +1453,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
1453
root,
1454
workInProgressRootRecoverableErrors,
1455
workInProgressTransitions,
1456
+ workInProgressRootDidIncludeRecursiveRenderUpdate,
1457
workInProgressDeferredLane,
1458
);
1459
@@ -1607,6 +1670,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1670
workInProgressDeferredLane = NoLane;
1671
workInProgressRootConcurrentErrors = null;
1672
workInProgressRootRecoverableErrors = null;
1673
+ workInProgressRootDidIncludeRecursiveRenderUpdate = false;
1674
1675
// Get the lanes that are entangled with whatever we're about to render. We
1676
// track these separately so we can distinguish the priority of the render
@@ -2675,6 +2739,7 @@ function commitRoot(
2739
root: FiberRoot,
2740
recoverableErrors: null | Array<CapturedValue<mixed>>,
2741
transitions: Array<Transition> | null,
2742
+ didIncludeRenderPhaseUpdate: boolean,
2743
spawnedLane: Lane,
2744
) {
2745
// TODO: This no longer makes any sense. We already wrap the mutation and
@@ -2689,6 +2754,7 @@ function commitRoot(
2754
root,
2755
recoverableErrors,
2756
transitions,
2757
+ didIncludeRenderPhaseUpdate,
2758
previousUpdateLanePriority,
2759
spawnedLane,
2760
);
@@ -2704,6 +2770,7 @@ function commitRootImpl(
2770
root: FiberRoot,
2771
recoverableErrors: null | Array<CapturedValue<mixed>>,
2772
transitions: Array<Transition> | null,
2773
+ didIncludeRenderPhaseUpdate: boolean,
2774
renderPriorityLevel: EventPriority,
2775
spawnedLane: Lane,
2776
) {
@@ -2784,6 +2851,9 @@ function commitRootImpl(
2851
2852
markRootFinished(root, remainingLanes, spawnedLane);
2853
2854
+ // Reset this before firing side effects so we can detect recursive updates.
2855
+ didIncludeCommitPhaseUpdate = false;
2856
+
2857
if (root === workInProgressRoot) {
2858
// We can reset these now that they are finished.
2859
workInProgressRoot = null;
@@ -3036,10 +3106,15 @@ function commitRootImpl(
3106
// hydration lanes in this check, because render triggered by selective
3107
// hydration is conceptually not an update.
3108
if (
3109
+ // Check if there was a recursive update spawned by this render, in either
3110
+ // the render phase or the commit phase. We track these explicitly because
3111
+ // we can't infer from the remaining lanes alone.
3112
+ (enableInfiniteRenderLoopDetection &&
3113
+ (didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate)) ||
3114
// 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)
3115
+ (includesSomeLane(lanes, UpdateLanes) &&
3116
+ // Did it schedule a sync update?
3117
+ includesSomeLane(remainingLanes, SyncUpdateLanes))
3118
) {
3119
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
3120
markNestedUpdateScheduled();
@@ -3582,6 +3657,19 @@ export function throwIfInfiniteUpdateLoopDetected() {
3657
rootWithNestedUpdates = null;
3658
rootWithPassiveNestedUpdates = null;
3659
3660
+ if (enableInfiniteRenderLoopDetection) {
3661
+ if (executionContext & RenderContext && workInProgressRoot !== null) {
3662
+ // We're in the render phase. Disable the concurrent error recovery
3663
+ // mechanism to ensure that the error we're about to throw gets handled.
3664
+ // We need it to trigger the nearest error boundary so that the infinite
3665
+ // update loop is broken.
3666
+ workInProgressRoot.errorRecoveryDisabledLanes = mergeLanes(
3667
+ workInProgressRoot.errorRecoveryDisabledLanes,
3668
+ workInProgressRootRenderLanes,
3669
+ );
3670
+ }
3671
+ }
3672
+
3673
throw new Error(
3674
'Maximum update depth exceeded. This can happen when a component ' +
3675
'repeatedly calls setState inside componentWillUpdate or ' +
packages/shared/ReactFeatureFlags.js
+6
@@ -170,6 +170,12 @@ export const disableClientCache = false;
170
// Changes Server Components Reconciliation when they have keys
171
export const enableServerComponentKeys = __NEXT_MAJOR__;
172
173
+/**
174
+ * Enables a new error detection for infinite render loops from updates caused
175
+ * by setState or similar outside of the component owning the state.
176
+ */
177
+export const enableInfiniteRenderLoopDetection = true;
178
+
179
// -----------------------------------------------------------------------------
180
// Chopping Block
181
//
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -94,6 +94,7 @@ export const enableUseDeferredValueInitialArg = true;
94
export const disableClientCache = true;
95
96
export const enableServerComponentKeys = true;
97
+export const enableInfiniteRenderLoopDetection = false;
98
99
// Flow magic to verify the exports of this file match the original version.
100
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -75,6 +75,7 @@ export const useModernStrictMode = false;
75
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
76
export const enableFizzExternalRuntime = false;
77
export const enableDeferRootSchedulingToMicrotask = false;
78
+export const enableInfiniteRenderLoopDetection = false;
79
80
export const enableAsyncActions = false;
81
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -86,6 +86,7 @@ export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
86
export const disableClientCache = true;
87
88
export const enableServerComponentKeys = true;
89
+export const enableInfiniteRenderLoopDetection = false;
90
91
// Flow magic to verify the exports of this file match the original version.
92
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -50,6 +50,7 @@ export const enableUseMemoCacheHook = true;
50
export const enableUseEffectEventHook = false;
51
export const enableClientRenderFallbackOnTextMismatch = true;
52
export const enableUseRefAccessWarning = false;
53
+export const enableInfiniteRenderLoopDetection = false;
54
55
export const enableRetryLaneExpiration = false;
56
export const retryLaneExpirationMs = 5000;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -86,6 +86,7 @@ export const enableUseDeferredValueInitialArg = true;
86
export const disableClientCache = true;
87
88
export const enableServerComponentKeys = true;
89
+export const enableInfiniteRenderLoopDetection = false;
90
91
// Flow magic to verify the exports of this file match the original version.
92
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+2
@@ -43,6 +43,8 @@ export const enableDebugTracing = __EXPERIMENTAL__;
43
44
export const enableSchedulingProfiler = __VARIANT__;
45
46
+export const enableInfiniteRenderLoopDetection = __VARIANT__;
47
+
48
// These are already tested in both modes using the build type dimension,
49
// so we don't need to use __VARIANT__ to get extra coverage.
50
export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -36,6 +36,7 @@ export const {
36
retryLaneExpirationMs,
37
syncLaneExpirationMs,
38
transitionLaneExpirationMs,
39
+ enableInfiniteRenderLoopDetection,
40
} = dynamicFeatureFlags;
41
42
// On WWW, __EXPERIMENTAL__ is used for a new modern build.