Skip to content

Commit ae39371

Browse files
committed
Detect non-synchronous recursive update loops
This improves our infinite loop detection by explicitly tracking when an update happens during the render or commit phase. Previously, to detect recursive update, we would check if there was synchronous work remaining after the commit phase — because synchronous work is the highest priority, the only way there could be even more synchronous work is if it was spawned by that render. But this approach won't work to detect async update loops.
1 parent 6adc321 commit ae39371

File tree

2 files changed

+112
-9
lines changed

2 files changed

+112
-9
lines changed

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,6 +1646,38 @@ describe('ReactUpdates', () => {
16461646
);
16471647
});
16481648

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+
16491681
// TODO: Replace this branch with @gate pragmas
16501682
if (__DEV__) {
16511683
it('warns about a deferred infinite update loop with useEffect', async () => {

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 80 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
}

0 commit comments

Comments
 (0)