Skip to content

Commit f57f15b

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 123a018 commit f57f15b

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
@@ -143,9 +143,9 @@ import {
143143
includesExpiredLane,
144144
getNextLanes,
145145
getLanesToRetrySynchronouslyOnError,
146-
markRootUpdated,
147-
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
148-
markRootPinged,
146+
markRootSuspended as _markRootSuspended,
147+
markRootUpdated as _markRootUpdated,
148+
markRootPinged as _markRootPinged,
149149
markRootEntangled,
150150
markRootFinished,
151151
addFiberToLanesMap,
@@ -372,6 +372,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
372372
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
373373
null;
374374

375+
// Tracks when an update occurs during the render phase.
376+
let workInProgressRootDidIncludeRecursiveRenderUpdate: boolean = false;
377+
// Thacks when an update occurs during the commit phase. It's a separate
378+
// variable from the one for renders because the commit phase may run
379+
// concurrently to a render phase.
380+
let didIncludeCommitPhaseUpdate: boolean = false;
381+
375382
// The most recent time we committed a fallback. This lets us ensure a train
376383
// model where we don't commit new loading states in too quick succession.
377384
let globalMostRecentFallbackTime: number = 0;
@@ -1117,6 +1124,7 @@ function finishConcurrentRender(
11171124
root,
11181125
workInProgressRootRecoverableErrors,
11191126
workInProgressTransitions,
1127+
workInProgressRootDidIncludeRecursiveRenderUpdate,
11201128
);
11211129
} else {
11221130
if (includesOnlyRetries(lanes)) {
@@ -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

@@ -2569,6 +2622,7 @@ function commitRoot(
25692622
root: FiberRoot,
25702623
recoverableErrors: null | Array<CapturedValue<mixed>>,
25712624
transitions: Array<Transition> | null,
2625+
didIncludeRenderPhaseUpdate: boolean,
25722626
) {
25732627
// TODO: This no longer makes any sense. We already wrap the mutation and
25742628
// layout phases. Should be able to remove.
@@ -2582,6 +2636,7 @@ function commitRoot(
25822636
root,
25832637
recoverableErrors,
25842638
transitions,
2639+
didIncludeRenderPhaseUpdate,
25852640
previousUpdateLanePriority,
25862641
);
25872642
} finally {
@@ -2596,6 +2651,7 @@ function commitRootImpl(
25962651
root: FiberRoot,
25972652
recoverableErrors: null | Array<CapturedValue<mixed>>,
25982653
transitions: Array<Transition> | null,
2654+
didIncludeRenderPhaseUpdate: boolean,
25992655
renderPriorityLevel: EventPriority,
26002656
) {
26012657
do {
@@ -2675,6 +2731,9 @@ function commitRootImpl(
26752731

26762732
markRootFinished(root, remainingLanes);
26772733

2734+
// Reset this before firing side effects so we can detect recursive updates.
2735+
didIncludeCommitPhaseUpdate = false;
2736+
26782737
if (root === workInProgressRoot) {
26792738
// We can reset these now that they are finished.
26802739
workInProgressRoot = null;
@@ -2921,7 +2980,19 @@ function commitRootImpl(
29212980

29222981
// Read this again, since a passive effect might have updated it
29232982
remainingLanes = root.pendingLanes;
2924-
if (includesSyncLane(remainingLanes)) {
2983+
if (
2984+
// Check if there was a recursive update spawned by this render, in either
2985+
// the render phase or the commit phase. We track these explicitly because
2986+
// we can't infer from the remaining lanes alone.
2987+
didIncludeCommitPhaseUpdate ||
2988+
didIncludeRenderPhaseUpdate ||
2989+
// As an additional precaution, we also check if there's any remaining sync
2990+
// work. Theoretically this should be unreachable but if there's a mistake
2991+
// in React it helps to be overly defensive given how hard it is to debug
2992+
// those scenarios otherwise. This won't catch recursive async updates,
2993+
// though, which is why we check the flags above first.
2994+
includesSyncLane(remainingLanes)
2995+
) {
29252996
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
29262997
markNestedUpdateScheduled();
29272998
}

0 commit comments

Comments
 (0)