Skip to content

Commit 0c1dfd7

Browse files
committed
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. DiffTrain build for [822386f252fd1f0e949efa904a1ed790133329f7](facebook/react@822386f)
1 parent f4de80a commit 0c1dfd7

20 files changed

+1912
-460
lines changed

compiled/facebook-www/REVISION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
80d9a40114bb43c07d021e8254790852f450bd2b
1+
822386f252fd1f0e949efa904a1ed790133329f7

compiled/facebook-www/React-dev.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ if (
2727
}
2828
"use strict";
2929

30-
var ReactVersion = "18.3.0-www-classic-70d18c58";
30+
var ReactVersion = "18.3.0-www-classic-7bcc71be";
3131

3232
// ATTENTION
3333
// When adding new symbols to this file,

compiled/facebook-www/React-dev.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ if (
2727
}
2828
"use strict";
2929

30-
var ReactVersion = "18.3.0-www-modern-6b726e24";
30+
var ReactVersion = "18.3.0-www-modern-fbda1d19";
3131

3232
// ATTENTION
3333
// When adding new symbols to this file,

compiled/facebook-www/React-prod.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,4 +622,4 @@ exports.useSyncExternalStore = function (
622622
);
623623
};
624624
exports.useTransition = useTransition;
625-
exports.version = "18.3.0-www-modern-ad08fdeb";
625+
exports.version = "18.3.0-www-modern-ead87ff5";

compiled/facebook-www/ReactART-dev.classic.js

Lines changed: 148 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function _assertThisInitialized(self) {
6969
return self;
7070
}
7171

72-
var ReactVersion = "18.3.0-www-classic-af595a64";
72+
var ReactVersion = "18.3.0-www-classic-4705a186";
7373

7474
var LegacyRoot = 0;
7575
var ConcurrentRoot = 1;
@@ -2165,7 +2165,7 @@ function createLaneMap(initial) {
21652165

21662166
return laneMap;
21672167
}
2168-
function markRootUpdated(root, updateLane) {
2168+
function markRootUpdated$1(root, updateLane) {
21692169
root.pendingLanes |= updateLane; // If there are any suspended transitions, it's possible this new update
21702170
// could unblock them. Clear the suspended lanes so that we can try rendering
21712171
// them again.
@@ -2198,7 +2198,7 @@ function markRootSuspended$1(root, suspendedLanes) {
21982198
lanes &= ~lane;
21992199
}
22002200
}
2201-
function markRootPinged(root, pingedLanes) {
2201+
function markRootPinged$1(root, pingedLanes) {
22022202
root.pingedLanes |= root.suspendedLanes & pingedLanes;
22032203
}
22042204
function markRootFinished(root, remainingLanes) {
@@ -7320,6 +7320,21 @@ function ensureRootIsScheduled(root) {
73207320
ReactCurrentActQueue$2.didScheduleLegacyUpdate = true;
73217321
}
73227322
}
7323+
7324+
function unscheduleAllRoots() {
7325+
// This is only done in a fatal error situation, as a last resort to prevent
7326+
// an infinite render loop.
7327+
var root = firstScheduledRoot;
7328+
7329+
while (root !== null) {
7330+
var next = root.next;
7331+
root.next = null;
7332+
root = next;
7333+
}
7334+
7335+
firstScheduledRoot = lastScheduledRoot = null;
7336+
}
7337+
73237338
function flushSyncWorkOnAllRoots() {
73247339
// This is allowed to be called synchronously, but the caller should check
73257340
// the execution context first.
@@ -7348,11 +7363,49 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy) {
73487363
var workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes(); // There may or may not be synchronous work scheduled. Let's check.
73497364

73507365
var didPerformSomeWork;
7366+
var nestedUpdatePasses = 0;
73517367
var errors = null;
73527368
isFlushingWork = true;
73537369

73547370
do {
7355-
didPerformSomeWork = false;
7371+
didPerformSomeWork = false; // This outer loop re-runs if performing sync work on a root spawns
7372+
// additional sync work. If it happens too many times, it's very likely
7373+
// caused by some sort of infinite update loop. We already have a loop guard
7374+
// in place that will trigger an error on the n+1th update, but it's
7375+
// possible for that error to get swallowed if the setState is called from
7376+
// an unexpected place, like during the render phase. So as an added
7377+
// precaution, we also use a guard here.
7378+
//
7379+
// Ideally, there should be no known way to trigger this synchronous loop.
7380+
// It's really just here as a safety net.
7381+
//
7382+
// This limit is slightly larger than the one that throws inside setState,
7383+
// because that one is preferable because it includes a componens stack.
7384+
7385+
if (++nestedUpdatePasses > 60) {
7386+
// This is a fatal error, so we'll unschedule all the roots.
7387+
unscheduleAllRoots(); // TODO: Change this error message to something different to distinguish
7388+
// it from the one that is thrown from setState. Those are less fatal
7389+
// because they usually will result in the bad component being unmounted,
7390+
// and an error boundary being triggered, rather than us having to
7391+
// forcibly stop the entire scheduler.
7392+
7393+
var infiniteUpdateError = new Error(
7394+
"Maximum update depth exceeded. This can happen when a component " +
7395+
"repeatedly calls setState inside componentWillUpdate or " +
7396+
"componentDidUpdate. React limits the number of nested updates to " +
7397+
"prevent infinite loops."
7398+
);
7399+
7400+
if (errors === null) {
7401+
errors = [infiniteUpdateError];
7402+
} else {
7403+
errors.push(infiniteUpdateError);
7404+
}
7405+
7406+
break;
7407+
}
7408+
73567409
var root = firstScheduledRoot;
73577410

73587411
while (root !== null) {
@@ -23668,7 +23721,13 @@ var workInProgressRootPingedLanes = NoLanes; // Errors that are thrown during th
2366823721
var workInProgressRootConcurrentErrors = null; // These are errors that we recovered from without surfacing them to the UI.
2366923722
// We will log them once the tree commits.
2367023723

23671-
var workInProgressRootRecoverableErrors = null; // The most recent time we either committed a fallback, or when a fallback was
23724+
var workInProgressRootRecoverableErrors = null; // Tracks when an update occurs during the render phase.
23725+
23726+
var workInProgressRootDidIncludeRecursiveRenderUpdate = false; // Thacks when an update occurs during the commit phase. It's a separate
23727+
// variable from the one for renders because the commit phase may run
23728+
// concurrently to a render phase.
23729+
23730+
var didIncludeCommitPhaseUpdate = false; // The most recent time we either committed a fallback, or when a fallback was
2367223731
// filled in with the resolved UI. This lets us throttle the appearance of new
2367323732
// content as it streams in, to minimize jank.
2367423733
// TODO: Think of a better name for this variable?
@@ -24347,7 +24406,8 @@ function finishConcurrentRender(root, exitStatus, finishedWork, lanes) {
2434724406
commitRoot(
2434824407
root,
2434924408
workInProgressRootRecoverableErrors,
24350-
workInProgressTransitions
24409+
workInProgressTransitions,
24410+
workInProgressRootDidIncludeRecursiveRenderUpdate
2435124411
);
2435224412
} else {
2435324413
if (
@@ -24380,6 +24440,7 @@ function finishConcurrentRender(root, exitStatus, finishedWork, lanes) {
2438024440
finishedWork,
2438124441
workInProgressRootRecoverableErrors,
2438224442
workInProgressTransitions,
24443+
workInProgressRootDidIncludeRecursiveRenderUpdate,
2438324444
lanes
2438424445
),
2438524446
msUntilTimeout
@@ -24393,6 +24454,7 @@ function finishConcurrentRender(root, exitStatus, finishedWork, lanes) {
2439324454
finishedWork,
2439424455
workInProgressRootRecoverableErrors,
2439524456
workInProgressTransitions,
24457+
workInProgressRootDidIncludeRecursiveRenderUpdate,
2439624458
lanes
2439724459
);
2439824460
}
@@ -24403,6 +24465,7 @@ function commitRootWhenReady(
2440324465
finishedWork,
2440424466
recoverableErrors,
2440524467
transitions,
24468+
didIncludeRenderPhaseUpdate,
2440624469
lanes
2440724470
) {
2440824471
// TODO: Combine retry throttling with Suspensey commits. Right now they run
@@ -24426,14 +24489,20 @@ function commitRootWhenReady(
2442624489
// us that it's ready. This will be canceled if we start work on the
2442724490
// root again.
2442824491
root.cancelPendingCommit = schedulePendingCommit(
24429-
commitRoot.bind(null, root, recoverableErrors, transitions)
24492+
commitRoot.bind(
24493+
null,
24494+
root,
24495+
recoverableErrors,
24496+
transitions,
24497+
didIncludeRenderPhaseUpdate
24498+
)
2443024499
);
2443124500
markRootSuspended(root, lanes);
2443224501
return;
2443324502
}
2443424503
} // Otherwise, commit immediately.
2443524504

24436-
commitRoot(root, recoverableErrors, transitions);
24505+
commitRoot(root, recoverableErrors, transitions, didIncludeRenderPhaseUpdate);
2443724506
}
2443824507

2443924508
function isRenderConsistentWithExternalStores(finishedWork) {
@@ -24496,18 +24565,49 @@ function isRenderConsistentWithExternalStores(finishedWork) {
2449624565
// eslint-disable-next-line no-unreachable
2449724566

2449824567
return true;
24568+
} // The extra indirections around markRootUpdated and markRootSuspended is
24569+
// needed to avoid a circular dependency between this module and
24570+
// ReactFiberLane. There's probably a better way to split up these modules and
24571+
// avoid this problem. Perhaps all the root-marking functions should move into
24572+
// the work loop.
24573+
24574+
function markRootUpdated(root, updatedLanes) {
24575+
markRootUpdated$1(root, updatedLanes); // Check for recursive updates
24576+
24577+
if (executionContext & RenderContext) {
24578+
workInProgressRootDidIncludeRecursiveRenderUpdate = true;
24579+
} else if (executionContext & CommitContext) {
24580+
didIncludeCommitPhaseUpdate = true;
24581+
}
24582+
24583+
throwIfInfiniteUpdateLoopDetected();
24584+
}
24585+
24586+
function markRootPinged(root, pingedLanes) {
24587+
markRootPinged$1(root, pingedLanes); // Check for recursive pings. Pings are conceptually different from updates in
24588+
// other contexts but we call it an "update" in this context because
24589+
// repeatedly pinging a suspended render can cause a recursive render loop.
24590+
// The relevant property is that it can result in a new render attempt
24591+
// being scheduled.
24592+
24593+
if (executionContext & RenderContext) {
24594+
workInProgressRootDidIncludeRecursiveRenderUpdate = true;
24595+
} else if (executionContext & CommitContext) {
24596+
didIncludeCommitPhaseUpdate = true;
24597+
}
24598+
24599+
throwIfInfiniteUpdateLoopDetected();
2449924600
}
2450024601

2450124602
function markRootSuspended(root, suspendedLanes) {
2450224603
// When suspending, we should always exclude lanes that were pinged or (more
2450324604
// rarely, since we try to avoid it) updated during the render phase.
24504-
// TODO: Lol maybe there's a better way to factor this besides this
24505-
// obnoxiously named function :)
2450624605
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
2450724606
suspendedLanes = removeLanes(
2450824607
suspendedLanes,
2450924608
workInProgressRootInterleavedUpdatedLanes
2451024609
);
24610+
2451124611
markRootSuspended$1(root, suspendedLanes);
2451224612
} // This is the entry point for synchronous tasks that don't go
2451324613
// through Scheduler
@@ -24578,7 +24678,8 @@ function performSyncWorkOnRoot(root) {
2457824678
commitRoot(
2457924679
root,
2458024680
workInProgressRootRecoverableErrors,
24581-
workInProgressTransitions
24681+
workInProgressTransitions,
24682+
workInProgressRootDidIncludeRecursiveRenderUpdate
2458224683
); // Before exiting, make sure there's a callback scheduled for the next
2458324684
// pending level.
2458424685

@@ -24704,6 +24805,7 @@ function prepareFreshStack(root, lanes) {
2470424805
workInProgressRootPingedLanes = NoLanes;
2470524806
workInProgressRootConcurrentErrors = null;
2470624807
workInProgressRootRecoverableErrors = null;
24808+
workInProgressRootDidIncludeRecursiveRenderUpdate = false;
2470724809
finishQueueingConcurrentUpdates();
2470824810

2470924811
{
@@ -25706,7 +25808,12 @@ function unwindUnitOfWork(unitOfWork) {
2570625808
workInProgress = null;
2570725809
}
2570825810

25709-
function commitRoot(root, recoverableErrors, transitions) {
25811+
function commitRoot(
25812+
root,
25813+
recoverableErrors,
25814+
transitions,
25815+
didIncludeRenderPhaseUpdate
25816+
) {
2571025817
// TODO: This no longer makes any sense. We already wrap the mutation and
2571125818
// layout phases. Should be able to remove.
2571225819
var previousUpdateLanePriority = getCurrentUpdatePriority();
@@ -25719,6 +25826,7 @@ function commitRoot(root, recoverableErrors, transitions) {
2571925826
root,
2572025827
recoverableErrors,
2572125828
transitions,
25829+
didIncludeRenderPhaseUpdate,
2572225830
previousUpdateLanePriority
2572325831
);
2572425832
} finally {
@@ -25733,6 +25841,7 @@ function commitRootImpl(
2573325841
root,
2573425842
recoverableErrors,
2573525843
transitions,
25844+
didIncludeRenderPhaseUpdate,
2573625845
renderPriorityLevel
2573725846
) {
2573825847
do {
@@ -25808,7 +25917,9 @@ function commitRootImpl(
2580825917

2580925918
var concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes();
2581025919
remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes);
25811-
markRootFinished(root, remainingLanes);
25920+
markRootFinished(root, remainingLanes); // Reset this before firing side effects so we can detect recursive updates.
25921+
25922+
didIncludeCommitPhaseUpdate = false;
2581225923

2581325924
if (root === workInProgressRoot) {
2581425925
// We can reset these now that they are finished.
@@ -26027,7 +26138,18 @@ function commitRootImpl(
2602726138

2602826139
remainingLanes = root.pendingLanes;
2602926140

26030-
if (includesSyncLane(remainingLanes)) {
26141+
if (
26142+
// Check if there was a recursive update spawned by this render, in either
26143+
// the render phase or the commit phase. We track these explicitly because
26144+
// we can't infer from the remaining lanes alone.
26145+
didIncludeCommitPhaseUpdate ||
26146+
didIncludeRenderPhaseUpdate || // As an additional precaution, we also check if there's any remaining sync
26147+
// work. Theoretically this should be unreachable but if there's a mistake
26148+
// in React it helps to be overly defensive given how hard it is to debug
26149+
// those scenarios otherwise. This won't catch recursive async updates,
26150+
// though, which is why we check the flags above first.
26151+
includesSyncLane(remainingLanes)
26152+
) {
2603126153
{
2603226154
markNestedUpdateScheduled();
2603326155
} // Count the number of times the root synchronously re-renders without
@@ -26522,6 +26644,18 @@ function throwIfInfiniteUpdateLoopDetected() {
2652226644
nestedPassiveUpdateCount = 0;
2652326645
rootWithNestedUpdates = null;
2652426646
rootWithPassiveNestedUpdates = null;
26647+
26648+
if (executionContext & RenderContext && workInProgressRoot !== null) {
26649+
// We're in the render phase. Disable the concurrent error recovery
26650+
// mechanism to ensure that the error we're about to throw gets handled.
26651+
// We need it to trigger the nearest error boundary so that the infinite
26652+
// update loop is broken.
26653+
workInProgressRoot.errorRecoveryDisabledLanes = mergeLanes(
26654+
workInProgressRoot.errorRecoveryDisabledLanes,
26655+
workInProgressRootRenderLanes
26656+
);
26657+
}
26658+
2652526659
throw new Error(
2652626660
"Maximum update depth exceeded. This can happen when a component " +
2652726661
"repeatedly calls setState inside componentWillUpdate or " +

0 commit comments

Comments
 (0)