Skip to content

Commit 17223cb

Browse files
kassensAndyPengc12
authored andcommitted
Add infinite update loop detection (facebook#28279)
This is a partial redo of facebook#26625. Since that was unlanded due to some detected breakages. This now includes a feature flag to be careful in rolling this out.
1 parent 1b03e3d commit 17223cb

10 files changed

+177
-11
lines changed

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,70 @@ 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+
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+
17121776
// TODO: Replace this branch with @gate pragmas
17131777
if (__DEV__) {
17141778
it('warns about a deferred infinite update loop with useEffect', async () => {

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
useModernStrictMode,
4141
disableLegacyContext,
4242
alwaysThrottleRetries,
43+
enableInfiniteRenderLoopDetection,
4344
} from 'shared/ReactFeatureFlags';
4445
import ReactSharedInternals from 'shared/ReactSharedInternals';
4546
import is from 'shared/objectIs';
@@ -147,10 +148,10 @@ import {
147148
getNextLanes,
148149
getEntangledLanes,
149150
getLanesToRetrySynchronouslyOnError,
150-
markRootUpdated,
151-
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
152-
markRootPinged,
153151
upgradePendingLanesToSync,
152+
markRootSuspended as _markRootSuspended,
153+
markRootUpdated as _markRootUpdated,
154+
markRootPinged as _markRootPinged,
154155
markRootFinished,
155156
addFiberToLanesMap,
156157
movePendingFibersToMemoized,
@@ -381,6 +382,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
381382
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
382383
null;
383384

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+
384392
// The most recent time we either committed a fallback, or when a fallback was
385393
// filled in with the resolved UI. This lets us throttle the appearance of new
386394
// content as it streams in, to minimize jank.
@@ -1154,6 +1162,7 @@ function finishConcurrentRender(
11541162
root,
11551163
workInProgressRootRecoverableErrors,
11561164
workInProgressTransitions,
1165+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11571166
workInProgressDeferredLane,
11581167
);
11591168
} else {
@@ -1189,6 +1198,7 @@ function finishConcurrentRender(
11891198
finishedWork,
11901199
workInProgressRootRecoverableErrors,
11911200
workInProgressTransitions,
1201+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11921202
lanes,
11931203
workInProgressDeferredLane,
11941204
),
@@ -1202,6 +1212,7 @@ function finishConcurrentRender(
12021212
finishedWork,
12031213
workInProgressRootRecoverableErrors,
12041214
workInProgressTransitions,
1215+
workInProgressRootDidIncludeRecursiveRenderUpdate,
12051216
lanes,
12061217
workInProgressDeferredLane,
12071218
);
@@ -1213,6 +1224,7 @@ function commitRootWhenReady(
12131224
finishedWork: Fiber,
12141225
recoverableErrors: Array<CapturedValue<mixed>> | null,
12151226
transitions: Array<Transition> | null,
1227+
didIncludeRenderPhaseUpdate: boolean,
12161228
lanes: Lanes,
12171229
spawnedLane: Lane,
12181230
) {
@@ -1240,15 +1252,27 @@ function commitRootWhenReady(
12401252
// us that it's ready. This will be canceled if we start work on the
12411253
// root again.
12421254
root.cancelPendingCommit = schedulePendingCommit(
1243-
commitRoot.bind(null, root, recoverableErrors, transitions),
1255+
commitRoot.bind(
1256+
null,
1257+
root,
1258+
recoverableErrors,
1259+
transitions,
1260+
didIncludeRenderPhaseUpdate,
1261+
),
12441262
);
12451263
markRootSuspended(root, lanes, spawnedLane);
12461264
return;
12471265
}
12481266
}
12491267

12501268
// Otherwise, commit immediately.
1251-
commitRoot(root, recoverableErrors, transitions, spawnedLane);
1269+
commitRoot(
1270+
root,
1271+
recoverableErrors,
1272+
transitions,
1273+
didIncludeRenderPhaseUpdate,
1274+
spawnedLane,
1275+
);
12521276
}
12531277

12541278
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
@@ -1304,21 +1328,59 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
13041328
return true;
13051329
}
13061330

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+
13071371
function markRootSuspended(
13081372
root: FiberRoot,
13091373
suspendedLanes: Lanes,
13101374
spawnedLane: Lane,
13111375
) {
13121376
// When suspending, we should always exclude lanes that were pinged or (more
13131377
// 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 :)
13161378
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
13171379
suspendedLanes = removeLanes(
13181380
suspendedLanes,
13191381
workInProgressRootInterleavedUpdatedLanes,
13201382
);
1321-
markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes, spawnedLane);
1383+
_markRootSuspended(root, suspendedLanes, spawnedLane);
13221384
}
13231385

13241386
// This is the entry point for synchronous tasks that don't go
@@ -1391,6 +1453,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
13911453
root,
13921454
workInProgressRootRecoverableErrors,
13931455
workInProgressTransitions,
1456+
workInProgressRootDidIncludeRecursiveRenderUpdate,
13941457
workInProgressDeferredLane,
13951458
);
13961459

@@ -1607,6 +1670,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
16071670
workInProgressDeferredLane = NoLane;
16081671
workInProgressRootConcurrentErrors = null;
16091672
workInProgressRootRecoverableErrors = null;
1673+
workInProgressRootDidIncludeRecursiveRenderUpdate = false;
16101674

16111675
// Get the lanes that are entangled with whatever we're about to render. We
16121676
// track these separately so we can distinguish the priority of the render
@@ -2675,6 +2739,7 @@ function commitRoot(
26752739
root: FiberRoot,
26762740
recoverableErrors: null | Array<CapturedValue<mixed>>,
26772741
transitions: Array<Transition> | null,
2742+
didIncludeRenderPhaseUpdate: boolean,
26782743
spawnedLane: Lane,
26792744
) {
26802745
// TODO: This no longer makes any sense. We already wrap the mutation and
@@ -2689,6 +2754,7 @@ function commitRoot(
26892754
root,
26902755
recoverableErrors,
26912756
transitions,
2757+
didIncludeRenderPhaseUpdate,
26922758
previousUpdateLanePriority,
26932759
spawnedLane,
26942760
);
@@ -2704,6 +2770,7 @@ function commitRootImpl(
27042770
root: FiberRoot,
27052771
recoverableErrors: null | Array<CapturedValue<mixed>>,
27062772
transitions: Array<Transition> | null,
2773+
didIncludeRenderPhaseUpdate: boolean,
27072774
renderPriorityLevel: EventPriority,
27082775
spawnedLane: Lane,
27092776
) {
@@ -2784,6 +2851,9 @@ function commitRootImpl(
27842851

27852852
markRootFinished(root, remainingLanes, spawnedLane);
27862853

2854+
// Reset this before firing side effects so we can detect recursive updates.
2855+
didIncludeCommitPhaseUpdate = false;
2856+
27872857
if (root === workInProgressRoot) {
27882858
// We can reset these now that they are finished.
27892859
workInProgressRoot = null;
@@ -3036,10 +3106,15 @@ function commitRootImpl(
30363106
// hydration lanes in this check, because render triggered by selective
30373107
// hydration is conceptually not an update.
30383108
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)) ||
30393114
// 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))
30433118
) {
30443119
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
30453120
markNestedUpdateScheduled();
@@ -3582,6 +3657,19 @@ export function throwIfInfiniteUpdateLoopDetected() {
35823657
rootWithNestedUpdates = null;
35833658
rootWithPassiveNestedUpdates = null;
35843659

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+
35853673
throw new Error(
35863674
'Maximum update depth exceeded. This can happen when a component ' +
35873675
'repeatedly calls setState inside componentWillUpdate or ' +

packages/shared/ReactFeatureFlags.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ export const disableClientCache = false;
170170
// Changes Server Components Reconciliation when they have keys
171171
export const enableServerComponentKeys = __NEXT_MAJOR__;
172172

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+
173179
// -----------------------------------------------------------------------------
174180
// Chopping Block
175181
//

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const enableUseDeferredValueInitialArg = true;
9494
export const disableClientCache = true;
9595

9696
export const enableServerComponentKeys = true;
97+
export const enableInfiniteRenderLoopDetection = false;
9798

9899
// Flow magic to verify the exports of this file match the original version.
99100
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

packages/shared/forks/ReactFeatureFlags.native-oss.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const useModernStrictMode = false;
7575
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
7676
export const enableFizzExternalRuntime = false;
7777
export const enableDeferRootSchedulingToMicrotask = false;
78+
export const enableInfiniteRenderLoopDetection = false;
7879

7980
export const enableAsyncActions = false;
8081

packages/shared/forks/ReactFeatureFlags.test-renderer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
8686
export const disableClientCache = true;
8787

8888
export const enableServerComponentKeys = true;
89+
export const enableInfiniteRenderLoopDetection = false;
8990

9091
// Flow magic to verify the exports of this file match the original version.
9192
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

packages/shared/forks/ReactFeatureFlags.test-renderer.native.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const enableUseMemoCacheHook = true;
5050
export const enableUseEffectEventHook = false;
5151
export const enableClientRenderFallbackOnTextMismatch = true;
5252
export const enableUseRefAccessWarning = false;
53+
export const enableInfiniteRenderLoopDetection = false;
5354

5455
export const enableRetryLaneExpiration = false;
5556
export const retryLaneExpirationMs = 5000;

packages/shared/forks/ReactFeatureFlags.test-renderer.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const enableUseDeferredValueInitialArg = true;
8686
export const disableClientCache = true;
8787

8888
export const enableServerComponentKeys = true;
89+
export const enableInfiniteRenderLoopDetection = false;
8990

9091
// Flow magic to verify the exports of this file match the original version.
9192
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

packages/shared/forks/ReactFeatureFlags.www-dynamic.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export const enableDebugTracing = __EXPERIMENTAL__;
4343

4444
export const enableSchedulingProfiler = __VARIANT__;
4545

46+
export const enableInfiniteRenderLoopDetection = __VARIANT__;
47+
4648
// These are already tested in both modes using the build type dimension,
4749
// so we don't need to use __VARIANT__ to get extra coverage.
4850
export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;

packages/shared/forks/ReactFeatureFlags.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const {
3636
retryLaneExpirationMs,
3737
syncLaneExpirationMs,
3838
transitionLaneExpirationMs,
39+
enableInfiniteRenderLoopDetection,
3940
} = dynamicFeatureFlags;
4041

4142
// On WWW, __EXPERIMENTAL__ is used for a new modern build.

0 commit comments

Comments
 (0)