@samitouri / QOS-React-1 / commits / d8c1fa6b0b

Add infinite update loop detection (#28279)

This is a partial redo of https://github.com/facebook/react/pull/26625. Since that was unlanded due to some detected breakages. This now includes a feature flag to be careful in rolling this out.

Jan Kassens committed Feb 9, 2024 at 11:14 UTC d8c1fa6b0b8da0512cb5acab9cd4f242451392f3
10 files changed +177 -11
packages/react-dom/src/__tests__/ReactUpdates-test.js
+64
@@ -1709,6 +1709,70 @@ describe('ReactUpdates', () => {
1709 expect(subscribers.length).toBe(limit);
1710 });
1711
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 +
1776 // TODO: Replace this branch with @gate pragmas
1777 if (__DEV__) {
1778 it('warns about a deferred infinite update loop with useEffect', async () => {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+99 -11
@@ -40,6 +40,7 @@ import {
40 useModernStrictMode,
41 disableLegacyContext,
42 alwaysThrottleRetries,
43 + enableInfiniteRenderLoopDetection,
44 } from 'shared/ReactFeatureFlags';
45 import ReactSharedInternals from 'shared/ReactSharedInternals';
46 import is from 'shared/objectIs';
@@ -147,10 +148,10 @@ import {
148 getNextLanes,
149 getEntangledLanes,
150 getLanesToRetrySynchronouslyOnError,
150 - markRootUpdated,
151 - markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
152 - markRootPinged,
151 upgradePendingLanesToSync,
152 + markRootSuspended as _markRootSuspended,
153 + markRootUpdated as _markRootUpdated,
154 + markRootPinged as _markRootPinged,
155 markRootFinished,
156 addFiberToLanesMap,
157 movePendingFibersToMemoized,
@@ -381,6 +382,13 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
382 let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
383 null;
384
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 +
392 // The most recent time we either committed a fallback, or when a fallback was
393 // filled in with the resolved UI. This lets us throttle the appearance of new
394 // content as it streams in, to minimize jank.
@@ -1154,6 +1162,7 @@ function finishConcurrentRender(
1162 root,
1163 workInProgressRootRecoverableErrors,
1164 workInProgressTransitions,
1165 + workInProgressRootDidIncludeRecursiveRenderUpdate,
1166 workInProgressDeferredLane,
1167 );
1168 } else {
@@ -1189,6 +1198,7 @@ function finishConcurrentRender(
1198 finishedWork,
1199 workInProgressRootRecoverableErrors,
1200 workInProgressTransitions,
1201 + workInProgressRootDidIncludeRecursiveRenderUpdate,
1202 lanes,
1203 workInProgressDeferredLane,
1204 ),
@@ -1202,6 +1212,7 @@ function finishConcurrentRender(
1212 finishedWork,
1213 workInProgressRootRecoverableErrors,
1214 workInProgressTransitions,
1215 + workInProgressRootDidIncludeRecursiveRenderUpdate,
1216 lanes,
1217 workInProgressDeferredLane,
1218 );
@@ -1213,6 +1224,7 @@ function commitRootWhenReady(
1224 finishedWork: Fiber,
1225 recoverableErrors: Array<CapturedValue<mixed>> | null,
1226 transitions: Array<Transition> | null,
1227 + didIncludeRenderPhaseUpdate: boolean,
1228 lanes: Lanes,
1229 spawnedLane: Lane,
1230 ) {
@@ -1240,7 +1252,13 @@ function commitRootWhenReady(
1252 // us that it's ready. This will be canceled if we start work on the
1253 // root again.
1254 root.cancelPendingCommit = schedulePendingCommit(
1243 - commitRoot.bind(null, root, recoverableErrors, transitions),
1255 + commitRoot.bind(
1256 + null,
1257 + root,
1258 + recoverableErrors,
1259 + transitions,
1260 + didIncludeRenderPhaseUpdate,
1261 + ),
1262 );
1263 markRootSuspended(root, lanes, spawnedLane);
1264 return;
@@ -1248,7 +1266,13 @@ function commitRootWhenReady(
1266 }
1267
1268 // Otherwise, commit immediately.
1251 - commitRoot(root, recoverableErrors, transitions, spawnedLane);
1269 + commitRoot(
1270 + root,
1271 + recoverableErrors,
1272 + transitions,
1273 + didIncludeRenderPhaseUpdate,
1274 + spawnedLane,
1275 + );
1276 }
1277
1278 function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
@@ -1304,6 +1328,46 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
1328 return true;
1329 }
1330
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 +
1371 function markRootSuspended(
1372 root: FiberRoot,
1373 suspendedLanes: Lanes,
@@ -1311,14 +1375,12 @@ function markRootSuspended(
1375 ) {
1376 // When suspending, we should always exclude lanes that were pinged or (more
1377 // 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 :)
1378 suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
1379 suspendedLanes = removeLanes(
1380 suspendedLanes,
1381 workInProgressRootInterleavedUpdatedLanes,
1382 );
1321 - markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes, spawnedLane);
1383 + _markRootSuspended(root, suspendedLanes, spawnedLane);
1384 }
1385
1386 // This is the entry point for synchronous tasks that don't go
@@ -1391,6 +1453,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
1453 root,
1454 workInProgressRootRecoverableErrors,
1455 workInProgressTransitions,
1456 + workInProgressRootDidIncludeRecursiveRenderUpdate,
1457 workInProgressDeferredLane,
1458 );
1459
@@ -1607,6 +1670,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1670 workInProgressDeferredLane = NoLane;
1671 workInProgressRootConcurrentErrors = null;
1672 workInProgressRootRecoverableErrors = null;
1673 + workInProgressRootDidIncludeRecursiveRenderUpdate = false;
1674
1675 // Get the lanes that are entangled with whatever we're about to render. We
1676 // track these separately so we can distinguish the priority of the render
@@ -2675,6 +2739,7 @@ function commitRoot(
2739 root: FiberRoot,
2740 recoverableErrors: null | Array<CapturedValue<mixed>>,
2741 transitions: Array<Transition> | null,
2742 + didIncludeRenderPhaseUpdate: boolean,
2743 spawnedLane: Lane,
2744 ) {
2745 // TODO: This no longer makes any sense. We already wrap the mutation and
@@ -2689,6 +2754,7 @@ function commitRoot(
2754 root,
2755 recoverableErrors,
2756 transitions,
2757 + didIncludeRenderPhaseUpdate,
2758 previousUpdateLanePriority,
2759 spawnedLane,
2760 );
@@ -2704,6 +2770,7 @@ function commitRootImpl(
2770 root: FiberRoot,
2771 recoverableErrors: null | Array<CapturedValue<mixed>>,
2772 transitions: Array<Transition> | null,
2773 + didIncludeRenderPhaseUpdate: boolean,
2774 renderPriorityLevel: EventPriority,
2775 spawnedLane: Lane,
2776 ) {
@@ -2784,6 +2851,9 @@ function commitRootImpl(
2851
2852 markRootFinished(root, remainingLanes, spawnedLane);
2853
2854 + // Reset this before firing side effects so we can detect recursive updates.
2855 + didIncludeCommitPhaseUpdate = false;
2856 +
2857 if (root === workInProgressRoot) {
2858 // We can reset these now that they are finished.
2859 workInProgressRoot = null;
@@ -3036,10 +3106,15 @@ function commitRootImpl(
3106 // hydration lanes in this check, because render triggered by selective
3107 // hydration is conceptually not an update.
3108 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)) ||
3114 // 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))
3118 ) {
3119 if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
3120 markNestedUpdateScheduled();
@@ -3582,6 +3657,19 @@ export function throwIfInfiniteUpdateLoopDetected() {
3657 rootWithNestedUpdates = null;
3658 rootWithPassiveNestedUpdates = null;
3659
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 +
3673 throw new Error(
3674 'Maximum update depth exceeded. This can happen when a component ' +
3675 'repeatedly calls setState inside componentWillUpdate or ' +
packages/shared/ReactFeatureFlags.js
+6
@@ -170,6 +170,12 @@ export const disableClientCache = false;
170 // Changes Server Components Reconciliation when they have keys
171 export const enableServerComponentKeys = __NEXT_MAJOR__;
172
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 +
179 // -----------------------------------------------------------------------------
180 // Chopping Block
181 //
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -94,6 +94,7 @@ export const enableUseDeferredValueInitialArg = true;
94 export const disableClientCache = true;
95
96 export const enableServerComponentKeys = true;
97 +export const enableInfiniteRenderLoopDetection = false;
98
99 // Flow magic to verify the exports of this file match the original version.
100 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -75,6 +75,7 @@ export const useModernStrictMode = false;
75 export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
76 export const enableFizzExternalRuntime = false;
77 export const enableDeferRootSchedulingToMicrotask = false;
78 +export const enableInfiniteRenderLoopDetection = false;
79
80 export const enableAsyncActions = false;
81
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -86,6 +86,7 @@ export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
86 export const disableClientCache = true;
87
88 export const enableServerComponentKeys = true;
89 +export const enableInfiniteRenderLoopDetection = false;
90
91 // Flow magic to verify the exports of this file match the original version.
92 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -50,6 +50,7 @@ export const enableUseMemoCacheHook = true;
50 export const enableUseEffectEventHook = false;
51 export const enableClientRenderFallbackOnTextMismatch = true;
52 export const enableUseRefAccessWarning = false;
53 +export const enableInfiniteRenderLoopDetection = false;
54
55 export const enableRetryLaneExpiration = false;
56 export const retryLaneExpirationMs = 5000;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -86,6 +86,7 @@ export const enableUseDeferredValueInitialArg = true;
86 export const disableClientCache = true;
87
88 export const enableServerComponentKeys = true;
89 +export const enableInfiniteRenderLoopDetection = false;
90
91 // Flow magic to verify the exports of this file match the original version.
92 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+2
@@ -43,6 +43,8 @@ export const enableDebugTracing = __EXPERIMENTAL__;
43
44 export const enableSchedulingProfiler = __VARIANT__;
45
46 +export const enableInfiniteRenderLoopDetection = __VARIANT__;
47 +
48 // These are already tested in both modes using the build type dimension,
49 // so we don't need to use __VARIANT__ to get extra coverage.
50 export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -36,6 +36,7 @@ export const {
36 retryLaneExpirationMs,
37 syncLaneExpirationMs,
38 transitionLaneExpirationMs,
39 + enableInfiniteRenderLoopDetection,
40 } = dynamicFeatureFlags;
41
42 // On WWW, __EXPERIMENTAL__ is used for a new modern build.