Skip to content

Commit aee7fed

Browse files
committed
Warn about async infinite useEffect loop
1 parent 3151813 commit aee7fed

File tree

2 files changed

+138
-0
lines changed

2 files changed

+138
-0
lines changed

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
let React;
1313
let ReactDOM;
1414
let ReactTestUtils;
15+
let Scheduler;
1516

1617
describe('ReactUpdates', () => {
1718
beforeEach(() => {
1819
jest.resetModules();
1920
React = require('react');
2021
ReactDOM = require('react-dom');
2122
ReactTestUtils = require('react-dom/test-utils');
23+
Scheduler = require('scheduler');
2224
});
2325

2426
it('should batch state when updating state twice', () => {
@@ -1524,4 +1526,99 @@ describe('ReactUpdates', () => {
15241526
});
15251527
});
15261528
});
1529+
1530+
if (__DEV__) {
1531+
it('warns about a deferred infinite update loop with useEffect', async () => {
1532+
function NonTerminating() {
1533+
const [step, setStep] = React.useState(0);
1534+
React.useEffect(() => {
1535+
setStep(x => x + 1);
1536+
Scheduler.yieldValue(step);
1537+
});
1538+
return step;
1539+
}
1540+
1541+
function App() {
1542+
return <NonTerminating />;
1543+
}
1544+
1545+
let error = null;
1546+
let stack = null;
1547+
let originalConsoleError = console.error;
1548+
console.error = (e, s1, s2) => {
1549+
error = e;
1550+
stack = s1 + s2;
1551+
};
1552+
try {
1553+
const container = document.createElement('div');
1554+
ReactDOM.render(<App />, container);
1555+
while (error === null) {
1556+
Scheduler.unstable_flushNumberOfYields(1);
1557+
await Promise.resolve();
1558+
}
1559+
expect(error).toContain('Warning: Maximum update depth exceeded.');
1560+
expect(stack).toContain('in NonTerminating');
1561+
} finally {
1562+
console.error = originalConsoleError;
1563+
}
1564+
});
1565+
1566+
it('can have nested updates if they do not cross the limit', async () => {
1567+
let _setStep;
1568+
const LIMIT = 50;
1569+
1570+
function Terminating() {
1571+
const [step, setStep] = React.useState(0);
1572+
_setStep = setStep;
1573+
React.useEffect(() => {
1574+
if (step < LIMIT) {
1575+
setStep(x => x + 1);
1576+
Scheduler.yieldValue(step);
1577+
}
1578+
});
1579+
return step;
1580+
}
1581+
1582+
const container = document.createElement('div');
1583+
ReactDOM.render(<Terminating />, container);
1584+
1585+
// Verify we can flush them asynchronously without warning
1586+
for (let i = 0; i < LIMIT * 2; i++) {
1587+
Scheduler.unstable_flushNumberOfYields(1);
1588+
await Promise.resolve();
1589+
}
1590+
expect(container.textContent).toBe('50');
1591+
1592+
// Verify restarting from 0 doesn't cross the limit
1593+
expect(() => {
1594+
_setStep(0);
1595+
}).toWarnDev(
1596+
'An update to Terminating inside a test was not wrapped in act',
1597+
);
1598+
expect(container.textContent).toBe('0');
1599+
for (let i = 0; i < LIMIT * 2; i++) {
1600+
Scheduler.unstable_flushNumberOfYields(1);
1601+
await Promise.resolve();
1602+
}
1603+
expect(container.textContent).toBe('50');
1604+
});
1605+
1606+
it('can have many updates inside useEffect without triggering a warning', () => {
1607+
function Terminating() {
1608+
const [step, setStep] = React.useState(0);
1609+
React.useEffect(() => {
1610+
for (let i = 0; i < 1000; i++) {
1611+
setStep(x => x + 1);
1612+
}
1613+
Scheduler.yieldValue('Done');
1614+
}, []);
1615+
return step;
1616+
}
1617+
1618+
const container = document.createElement('div');
1619+
ReactDOM.render(<Terminating />, container);
1620+
expect(Scheduler).toFlushAndYield(['Done']);
1621+
expect(container.textContent).toBe('1000');
1622+
});
1623+
}
15271624
});

packages/react-reconciler/src/ReactFiberScheduler.old.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
} from 'shared/ReactFeatureFlags';
6868
import getComponentName from 'shared/getComponentName';
6969
import invariant from 'shared/invariant';
70+
import warning from 'shared/warning';
7071
import warningWithoutStack from 'shared/warningWithoutStack';
7172

7273
import ReactFiberInstrumentation from './ReactFiberInstrumentation';
@@ -547,7 +548,9 @@ function commitPassiveEffects(root: FiberRoot, firstEffect: Fiber): void {
547548
let didError = false;
548549
let error;
549550
if (__DEV__) {
551+
isInPassiveEffectDEV = true;
550552
invokeGuardedCallback(null, commitPassiveHookEffects, null, effect);
553+
isInPassiveEffectDEV = false;
551554
if (hasCaughtError()) {
552555
didError = true;
553556
error = clearCaughtError();
@@ -581,6 +584,14 @@ function commitPassiveEffects(root: FiberRoot, firstEffect: Fiber): void {
581584
if (!isBatchingUpdates && !isRendering) {
582585
performSyncWork();
583586
}
587+
588+
if (__DEV__) {
589+
if (rootWithPendingPassiveEffects === root) {
590+
nestedPassiveEffectCountDEV++;
591+
} else {
592+
nestedPassiveEffectCountDEV = 0;
593+
}
594+
}
584595
}
585596

586597
function isAlreadyFailedLegacyErrorBoundary(instance: mixed): boolean {
@@ -1897,6 +1908,21 @@ function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
18971908
'the number of nested updates to prevent infinite loops.',
18981909
);
18991910
}
1911+
if (__DEV__) {
1912+
if (
1913+
isInPassiveEffectDEV &&
1914+
nestedPassiveEffectCountDEV > NESTED_PASSIVE_UPDATE_LIMIT
1915+
) {
1916+
nestedPassiveEffectCountDEV = 0;
1917+
warning(
1918+
false,
1919+
'Maximum update depth exceeded. This can happen when a ' +
1920+
'component calls setState inside useEffect, but ' +
1921+
"useEffect either doesn't have a dependency array, or " +
1922+
'one of the dependencies changes on every render.',
1923+
);
1924+
}
1925+
}
19001926
}
19011927

19021928
function deferredUpdates<A>(fn: () => A): A {
@@ -1962,6 +1988,15 @@ const NESTED_UPDATE_LIMIT = 50;
19621988
let nestedUpdateCount: number = 0;
19631989
let lastCommittedRootDuringThisBatch: FiberRoot | null = null;
19641990

1991+
// Similar, but for useEffect infinite loops. These are DEV-only.
1992+
const NESTED_PASSIVE_UPDATE_LIMIT = 50;
1993+
let nestedPassiveEffectCountDEV;
1994+
let isInPassiveEffectDEV;
1995+
if (__DEV__) {
1996+
nestedPassiveEffectCountDEV = 0;
1997+
isInPassiveEffectDEV = false;
1998+
}
1999+
19652000
function recomputeCurrentRendererTime() {
19662001
const currentTimeMs = now() - originalStartTimeMs;
19672002
currentRendererTime = msToExpirationTime(currentTimeMs);
@@ -2326,6 +2361,12 @@ function finishRendering() {
23262361
nestedUpdateCount = 0;
23272362
lastCommittedRootDuringThisBatch = null;
23282363

2364+
if (__DEV__) {
2365+
if (rootWithPendingPassiveEffects === null) {
2366+
nestedPassiveEffectCountDEV = 0;
2367+
}
2368+
}
2369+
23292370
if (completedBatches !== null) {
23302371
const batches = completedBatches;
23312372
completedBatches = null;

0 commit comments

Comments
 (0)