@@ -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 =
370370let 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
12101227function 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+
12631316function 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 }
@@ -3471,6 +3542,17 @@ export function throwIfInfiniteUpdateLoopDetected() {
34713542 rootWithNestedUpdates = null ;
34723543 rootWithPassiveNestedUpdates = null ;
34733544
3545+ if ( executionContext & RenderContext && workInProgressRoot !== null ) {
3546+ // We're in the render phase. Disable the concurrent error recovery
3547+ // mechanism to ensure that the error we're about to throw gets handled.
3548+ // We need it to trigger the nearest error boundary so that the infinite
3549+ // update loop is broken.
3550+ workInProgressRoot . errorRecoveryDisabledLanes = mergeLanes (
3551+ workInProgressRoot . errorRecoveryDisabledLanes ,
3552+ workInProgressRootRenderLanes ,
3553+ ) ;
3554+ }
3555+
34743556 throw new Error (
34753557 'Maximum update depth exceeded. This can happen when a component ' +
34763558 'repeatedly calls setState inside componentWillUpdate or ' +
0 commit comments