diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
index 33d7cec90ee68..3c7972aff4df8 100644
--- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
@@ -13,12 +13,14 @@
let React;
let ReactTestRenderer;
let ReactDebugTools;
+let act;
-describe('ReactHooksInspectionIntergration', () => {
+describe('ReactHooksInspectionIntegration', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactTestRenderer = require('react-test-renderer');
+ act = ReactTestRenderer.act;
ReactDebugTools = require('react-debug-tools');
});
@@ -47,7 +49,7 @@ describe('ReactHooksInspectionIntergration', () => {
onMouseUp: setStateB,
} = renderer.root.findByType('div').props;
- setStateA('Hi');
+ act(() => setStateA('Hi'));
childFiber = renderer.root.findByType(Foo)._currentFiber();
tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -57,7 +59,7 @@ describe('ReactHooksInspectionIntergration', () => {
{name: 'State', value: 'world', subHooks: []},
]);
- setStateB('world!');
+ act(() => setStateB('world!'));
childFiber = renderer.root.findByType(Foo)._currentFiber();
tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -91,8 +93,12 @@ describe('ReactHooksInspectionIntergration', () => {
React.useMemo(() => state1 + state2, [state1]);
function update() {
- setState('A');
- dispatch({value: 'B'});
+ act(() => {
+ setState('A');
+ });
+ act(() => {
+ dispatch({value: 'B'});
+ });
ref.current = 'C';
}
let memoizedUpdate = React.useCallback(update, []);
diff --git a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
index bc2ae71673d22..985827e53bfd7 100644
--- a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
@@ -13,7 +13,9 @@ let React;
let ReactDOM;
let Suspense;
let ReactCache;
+let ReactTestUtils;
let TextResource;
+let act;
describe('ReactDOMSuspensePlaceholder', () => {
let container;
@@ -23,6 +25,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
React = require('react');
ReactDOM = require('react-dom');
ReactCache = require('react-cache');
+ ReactTestUtils = require('react-dom/test-utils');
+ act = ReactTestUtils.act;
Suspense = React.Suspense;
container = document.createElement('div');
document.body.appendChild(container);
@@ -142,12 +146,14 @@ describe('ReactDOMSuspensePlaceholder', () => {
);
}
- ReactDOM.render(, container);
+ act(() => {
+ ReactDOM.render(, container);
+ });
expect(container.innerHTML).toEqual(
'SiblingLoading...',
);
- setIsVisible(true);
+ act(() => setIsVisible(true));
expect(container.innerHTML).toEqual(
'SiblingLoading...',
);
diff --git a/packages/react-dom/src/__tests__/ReactTestUtils-test.js b/packages/react-dom/src/__tests__/ReactTestUtils-test.js
index 687dbd1aaec2c..49d64fe3ba2bb 100644
--- a/packages/react-dom/src/__tests__/ReactTestUtils-test.js
+++ b/packages/react-dom/src/__tests__/ReactTestUtils-test.js
@@ -14,6 +14,7 @@ let React;
let ReactDOM;
let ReactDOMServer;
let ReactTestUtils;
+let act;
function getTestDocument(markup) {
const doc = document.implementation.createHTMLDocument('');
@@ -33,6 +34,7 @@ describe('ReactTestUtils', () => {
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
ReactTestUtils = require('react-dom/test-utils');
+ act = ReactTestUtils.act;
});
it('Simulate should have locally attached media events', () => {
@@ -515,4 +517,168 @@ describe('ReactTestUtils', () => {
ReactTestUtils.renderIntoDocument();
expect(mockArgs.length).toEqual(0);
});
+
+ it('can use act to batch effects', () => {
+ function App(props) {
+ React.useEffect(props.callback);
+ return null;
+ }
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ try {
+ let called = false;
+ act(() => {
+ ReactDOM.render(
+ {
+ called = true;
+ }}
+ />,
+ container,
+ );
+ });
+
+ expect(called).toBe(true);
+ } finally {
+ document.body.removeChild(container);
+ }
+ });
+
+ it('flushes effects on every call', () => {
+ function App(props) {
+ let [ctr, setCtr] = React.useState(0);
+ React.useEffect(() => {
+ props.callback(ctr);
+ });
+ return (
+
+ );
+ }
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ let calledCtr = 0;
+ act(() => {
+ ReactDOM.render(
+ {
+ calledCtr = val;
+ }}
+ />,
+ container,
+ );
+ });
+ const button = document.getElementById('button');
+ function click() {
+ button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+ }
+
+ act(() => {
+ click();
+ click();
+ click();
+ });
+ expect(calledCtr).toBe(3);
+ act(click);
+ expect(calledCtr).toBe(4);
+ act(click);
+ expect(calledCtr).toBe(5);
+
+ document.body.removeChild(container);
+ });
+
+ it('can use act to batch effects on updates too', () => {
+ function App() {
+ let [ctr, setCtr] = React.useState(0);
+ return (
+
+ );
+ }
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ let button;
+ act(() => {
+ ReactDOM.render(, container);
+ });
+ button = document.getElementById('button');
+ expect(button.innerHTML).toBe('0');
+ act(() => {
+ button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+ });
+ expect(button.innerHTML).toBe('1');
+ document.body.removeChild(container);
+ });
+
+ it('detects setState being called outside of act(...)', () => {
+ let setValueRef = null;
+ function App() {
+ let [value, setValue] = React.useState(0);
+ setValueRef = setValue;
+ return (
+
+ );
+ }
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ let button;
+ act(() => {
+ ReactDOM.render(, container);
+ button = container.querySelector('#button');
+ button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+ });
+ expect(button.innerHTML).toBe('2');
+ expect(() => setValueRef(1)).toWarnDev(
+ ['An update to App inside a test was not wrapped in act(...).'],
+ {withoutStack: 1},
+ );
+ document.body.removeChild(container);
+ });
+
+ it('lets a ticker update', () => {
+ function App() {
+ let [toggle, setToggle] = React.useState(0);
+ React.useEffect(() => {
+ let timeout = setTimeout(() => {
+ setToggle(1);
+ }, 200);
+ return () => clearTimeout(timeout);
+ });
+ return toggle;
+ }
+ const container = document.createElement('div');
+
+ act(() => {
+ act(() => {
+ ReactDOM.render(, container);
+ });
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(container.innerHTML).toBe('1');
+ });
+
+ it('warns if you return a value inside act', () => {
+ expect(() => act(() => 123)).toWarnDev(
+ [
+ 'The callback passed to ReactTestUtils.act(...) function must not return anything.',
+ ],
+ {withoutStack: true},
+ );
+ });
+
+ it('warns if you try to await an .act call', () => {
+ expect(act(() => {}).then).toWarnDev(
+ [
+ 'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
+ ],
+ {withoutStack: true},
+ );
+ });
});
diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js
index b596a1cff58f4..5bf6755bbbd62 100644
--- a/packages/react-dom/src/test-utils/ReactTestUtils.js
+++ b/packages/react-dom/src/test-utils/ReactTestUtils.js
@@ -18,9 +18,15 @@ import {
import SyntheticEvent from 'events/SyntheticEvent';
import invariant from 'shared/invariant';
import lowPriorityWarning from 'shared/lowPriorityWarning';
+import warningWithoutStack from 'shared/warningWithoutStack';
import {ELEMENT_NODE} from '../shared/HTMLNodeType';
import * as DOMTopLevelEventTypes from '../events/DOMTopLevelEventTypes';
+// for .act's return value
+type Thenable = {
+ then(resolve: () => mixed, reject?: () => mixed): mixed,
+};
+
const {findDOMNode} = ReactDOM;
// Keep in sync with ReactDOMUnstableNativeDependencies.js
// and ReactDOM.js:
@@ -145,6 +151,9 @@ function validateClassInstance(inst, methodName) {
);
}
+// stub element used by act() when flushing effects
+let actContainerElement = document.createElement('div');
+
/**
* Utilities for making it easy to test React components.
*
@@ -380,6 +389,43 @@ const ReactTestUtils = {
Simulate: null,
SimulateNative: {},
+
+ act(callback: () => void): Thenable {
+ // note: keep these warning messages in sync with
+ // createReactNoop.js and ReactTestRenderer.js
+ const result = ReactDOM.unstable_batchedUpdates(callback);
+ if (__DEV__) {
+ if (result !== undefined) {
+ let addendum;
+ if (typeof result.then === 'function') {
+ addendum =
+ '\n\nIt looks like you wrote ReactTestUtils.act(async () => ...), ' +
+ 'or returned a Promise from the callback passed to it. ' +
+ 'Putting asynchronous logic inside ReactTestUtils.act(...) is not supported.\n';
+ } else {
+ addendum = ' You returned: ' + result;
+ }
+ warningWithoutStack(
+ false,
+ 'The callback passed to ReactTestUtils.act(...) function must not return anything.%s',
+ addendum,
+ );
+ }
+ }
+ ReactDOM.render(, actContainerElement);
+ // we want the user to not expect a return,
+ // but we want to warn if they use it like they can await on it.
+ return {
+ then() {
+ if (__DEV__) {
+ warningWithoutStack(
+ false,
+ 'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
+ );
+ }
+ },
+ };
+ },
};
/**
diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js
index 25d2722f5764c..491ab5948ba0b 100644
--- a/packages/react-noop-renderer/src/createReactNoop.js
+++ b/packages/react-noop-renderer/src/createReactNoop.js
@@ -21,6 +21,12 @@ import type {ReactNodeList} from 'shared/ReactTypes';
import {createPortal} from 'shared/ReactPortal';
import expect from 'expect';
import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
+import warningWithoutStack from 'shared/warningWithoutStack';
+
+// for .act's return value
+type Thenable = {
+ then(resolve: () => mixed, reject?: () => mixed): mixed,
+};
type Container = {
rootID: string,
@@ -864,6 +870,43 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
interactiveUpdates: NoopRenderer.interactiveUpdates,
+ // maybe this should exist only in the test file
+ act(callback: () => void): Thenable {
+ // note: keep these warning messages in sync with
+ // ReactTestRenderer.js and ReactTestUtils.js
+ let result = NoopRenderer.batchedUpdates(callback);
+ if (__DEV__) {
+ if (result !== undefined) {
+ let addendum;
+ if (typeof result.then === 'function') {
+ addendum =
+ "\n\nIt looks like you wrote ReactNoop.act(async () => ...) or returned a Promise from it's callback. " +
+ 'Putting asynchronous logic inside ReactNoop.act(...) is not supported.\n';
+ } else {
+ addendum = ' You returned: ' + result;
+ }
+ warningWithoutStack(
+ false,
+ 'The callback passed to ReactNoop.act(...) function must not return anything.%s',
+ addendum,
+ );
+ }
+ }
+ ReactNoop.flushPassiveEffects();
+ // we want the user to not expect a return,
+ // but we want to warn if they use it like they can await on it.
+ return {
+ then() {
+ if (__DEV__) {
+ warningWithoutStack(
+ false,
+ 'Do not await the result of calling ReactNoop.act(...), it is not a Promise.',
+ );
+ }
+ },
+ };
+ },
+
flushSync(fn: () => mixed) {
yieldedValues = [];
NoopRenderer.flushSync(fn);
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index fef20fd1ec07c..1443fb544cdd1 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -30,6 +30,7 @@ import {
} from './ReactHookEffectTags';
import {
scheduleWork,
+ warnIfNotCurrentlyBatchingInDev,
computeExpirationForFiber,
flushPassiveEffects,
requestCurrentTime,
@@ -1003,6 +1004,19 @@ function updateMemo(
return nextValue;
}
+// in a test-like environment, we want to warn if dispatchAction()
+// is called outside of a batchedUpdates/TestUtils.act(...) call.
+let shouldWarnForUnbatchedSetState = false;
+
+if (__DEV__) {
+ // jest isnt' a 'global', it's just exposed to tests via a wrapped function
+ // further, this isn't a test file, so flow doesn't recognize the symbol. So...
+ // $FlowExpectedError - because requirements don't give a damn about your type sigs.
+ if ('undefined' !== typeof jest) {
+ shouldWarnForUnbatchedSetState = true;
+ }
+}
+
function dispatchAction(
fiber: Fiber,
queue: UpdateQueue,
@@ -1121,6 +1135,11 @@ function dispatchAction(
}
}
}
+ if (__DEV__) {
+ if (shouldWarnForUnbatchedSetState === true) {
+ warnIfNotCurrentlyBatchingInDev(fiber);
+ }
+ }
scheduleWork(fiber, expirationTime);
}
}
diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js
index 928a3616a0021..5712dbc9cd6d1 100644
--- a/packages/react-reconciler/src/ReactFiberScheduler.js
+++ b/packages/react-reconciler/src/ReactFiberScheduler.js
@@ -1790,6 +1790,25 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
return root;
}
+export function warnIfNotCurrentlyBatchingInDev(fiber: Fiber): void {
+ if (__DEV__) {
+ if (isRendering === false && isBatchingUpdates === false) {
+ warningWithoutStack(
+ false,
+ 'An update to %s inside a test was not wrapped in act(...).\n\n' +
+ 'When testing, code that causes React state updates should be wrapped into act(...):\n\n' +
+ 'act(() => {\n' +
+ ' /* fire events that update state */\n' +
+ '});\n' +
+ '/* assert on the output */\n\n' +
+ "This ensures that you're testing the behavior the user would see in the browser." +
+ ' Learn more at https://fb.me/react-wrap-tests-with-act',
+ getComponentName(fiber.type),
+ );
+ }
+ }
+}
+
function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
const root = scheduleWorkToRoot(fiber, expirationTime);
if (root === null) {
diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
index 6a8924e668a3d..ec5ca92aa060b 100644
--- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
@@ -16,6 +16,7 @@ let React;
let ReactFeatureFlags;
let ReactTestRenderer;
let ReactDOMServer;
+let act;
// Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to
// gradually migrate those to this file.
@@ -28,6 +29,7 @@ describe('ReactHooks', () => {
React = require('react');
ReactTestRenderer = require('react-test-renderer');
ReactDOMServer = require('react-dom/server');
+ act = ReactTestRenderer.act;
});
if (__DEV__) {
@@ -81,8 +83,11 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('0, 0');
// Normal update
- setCounter1(1);
- setCounter2(1);
+ act(() => {
+ setCounter1(1);
+ setCounter2(1);
+ });
+
expect(root).toFlushAndYield([
'Parent: 1, 1',
'Child: 1, 1',
@@ -90,13 +95,16 @@ describe('ReactHooks', () => {
]);
// Update that bails out.
- setCounter1(1);
+ act(() => setCounter1(1));
expect(root).toFlushAndYield(['Parent: 1, 1']);
// This time, one of the state updates but the other one doesn't. So we
// can't bail out.
- setCounter1(1);
- setCounter2(2);
+ act(() => {
+ setCounter1(1);
+ setCounter2(2);
+ });
+
expect(root).toFlushAndYield([
'Parent: 1, 2',
'Child: 1, 2',
@@ -104,20 +112,24 @@ describe('ReactHooks', () => {
]);
// Lots of updates that eventually resolve to the current values.
- setCounter1(9);
- setCounter2(3);
- setCounter1(4);
- setCounter2(7);
- setCounter1(1);
- setCounter2(2);
+ act(() => {
+ setCounter1(9);
+ setCounter2(3);
+ setCounter1(4);
+ setCounter2(7);
+ setCounter1(1);
+ setCounter2(2);
+ });
// Because the final values are the same as the current values, the
// component bails out.
expect(root).toFlushAndYield(['Parent: 1, 2']);
// prepare to check SameValue
- setCounter1(0 / -1);
- setCounter2(NaN);
+ act(() => {
+ setCounter1(0 / -1);
+ setCounter2(NaN);
+ });
expect(root).toFlushAndYield([
'Parent: 0, NaN',
'Child: 0, NaN',
@@ -125,14 +137,19 @@ describe('ReactHooks', () => {
]);
// check if re-setting to negative 0 / NaN still bails out
- setCounter1(0 / -1);
- setCounter2(NaN);
- setCounter2(Infinity);
- setCounter2(NaN);
+ act(() => {
+ setCounter1(0 / -1);
+ setCounter2(NaN);
+ setCounter2(Infinity);
+ setCounter2(NaN);
+ });
+
expect(root).toFlushAndYield(['Parent: 0, NaN']);
// check if changing negative 0 to positive 0 does not bail out
- setCounter1(0);
+ act(() => {
+ setCounter1(0);
+ });
expect(root).toFlushAndYield([
'Parent: 0, NaN',
'Child: 0, NaN',
@@ -172,21 +189,27 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('0, 0 (light)');
// Normal update
- setCounter1(1);
- setCounter2(1);
+ act(() => {
+ setCounter1(1);
+ setCounter2(1);
+ });
+
expect(root).toFlushAndYield([
'Parent: 1, 1 (light)',
'Child: 1, 1 (light)',
]);
// Update that bails out.
- setCounter1(1);
+ act(() => setCounter1(1));
expect(root).toFlushAndYield(['Parent: 1, 1 (light)']);
// This time, one of the state updates but the other one doesn't. So we
// can't bail out.
- setCounter1(1);
- setCounter2(2);
+ act(() => {
+ setCounter1(1);
+ setCounter2(2);
+ });
+
expect(root).toFlushAndYield([
'Parent: 1, 2 (light)',
'Child: 1, 2 (light)',
@@ -194,14 +217,20 @@ describe('ReactHooks', () => {
// Updates bail out, but component still renders because props
// have changed
- setCounter1(1);
- setCounter2(2);
+ act(() => {
+ setCounter1(1);
+ setCounter2(2);
+ });
+
root.update();
expect(root).toFlushAndYield(['Parent: 1, 2 (dark)', 'Child: 1, 2 (dark)']);
// Both props and state bail out
- setCounter1(1);
- setCounter2(2);
+ act(() => {
+ setCounter1(1);
+ setCounter2(2);
+ });
+
root.update();
expect(root).toFlushAndYield(['Parent: 1, 2 (dark)']);
});
@@ -224,9 +253,11 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('0');
expect(() => {
- setCounter(1, () => {
- throw new Error('Expected to ignore the callback.');
- });
+ act(() =>
+ setCounter(1, () => {
+ throw new Error('Expected to ignore the callback.');
+ }),
+ );
}).toWarnDev(
'State updates from the useState() and useReducer() Hooks ' +
"don't support the second callback argument. " +
@@ -256,9 +287,11 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('0');
expect(() => {
- dispatch(1, () => {
- throw new Error('Expected to ignore the callback.');
- });
+ act(() =>
+ dispatch(1, () => {
+ throw new Error('Expected to ignore the callback.');
+ }),
+ );
}).toWarnDev(
'State updates from the useState() and useReducer() Hooks ' +
"don't support the second callback argument. " +
@@ -326,7 +359,7 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('0 (light)');
// Normal update
- setCounter(1);
+ act(() => setCounter(1));
expect(root).toFlushAndYield([
'Parent: 1 (light)',
'Child: 1 (light)',
@@ -335,14 +368,17 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('1 (light)');
// Update that doesn't change state, so it bails out
- setCounter(1);
+ act(() => setCounter(1));
expect(root).toFlushAndYield(['Parent: 1 (light)']);
expect(root).toMatchRenderedOutput('1 (light)');
// Update that doesn't change state, but the context changes, too, so it
// can't bail out
- setCounter(1);
- setTheme('dark');
+ act(() => {
+ setCounter(1);
+ setTheme('dark');
+ });
+
expect(root).toFlushAndYield([
'Theme: dark',
'Parent: 1 (dark)',
@@ -377,7 +413,7 @@ describe('ReactHooks', () => {
expect(root).toMatchRenderedOutput('0');
// Normal update
- setCounter(1);
+ act(() => setCounter(1));
expect(root).toFlushAndYield(['Parent: 1', 'Child: 1', 'Effect: 1']);
expect(root).toMatchRenderedOutput('1');
@@ -385,38 +421,47 @@ describe('ReactHooks', () => {
// because the alterate fiber has pending update priority, so we have to
// enter the render phase before we can bail out. But we bail out before
// rendering the child, and we don't fire any effects.
- setCounter(1);
+ act(() => setCounter(1));
expect(root).toFlushAndYield(['Parent: 1']);
expect(root).toMatchRenderedOutput('1');
// Update to the same state again. This times, neither fiber has pending
// update priority, so we can bail out before even entering the render phase.
- setCounter(1);
+ act(() => setCounter(1));
expect(root).toFlushAndYield([]);
expect(root).toMatchRenderedOutput('1');
// This changes the state to something different so it renders normally.
- setCounter(2);
+ act(() => setCounter(2));
expect(root).toFlushAndYield(['Parent: 2', 'Child: 2', 'Effect: 2']);
expect(root).toMatchRenderedOutput('2');
// prepare to check SameValue
- setCounter(0);
+ act(() => {
+ setCounter(0);
+ });
expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
expect(root).toMatchRenderedOutput('0');
// Update to the same state for the first time to flush the queue
- setCounter(0);
+ act(() => {
+ setCounter(0);
+ });
+
expect(root).toFlushAndYield(['Parent: 0']);
expect(root).toMatchRenderedOutput('0');
// Update again to the same state. Should bail out.
- setCounter(0);
+ act(() => {
+ setCounter(0);
+ });
expect(root).toFlushAndYield([]);
expect(root).toMatchRenderedOutput('0');
// Update to a different state (positive 0 to negative 0)
- setCounter(0 / -1);
+ act(() => {
+ setCounter(0 / -1);
+ });
expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
expect(root).toMatchRenderedOutput('0');
});
@@ -450,12 +495,14 @@ describe('ReactHooks', () => {
return value;
});
};
- update(0);
- update(0);
- update(0);
- update(1);
- update(2);
- update(3);
+ act(() => {
+ update(0);
+ update(0);
+ update(0);
+ update(1);
+ update(2);
+ update(3);
+ });
expect(ReactTestRenderer).toHaveYielded([
// The first four updates were eagerly computed, because the queue is
@@ -511,7 +558,7 @@ describe('ReactHooks', () => {
};
// Update at normal priority
- update(n => n * 100);
+ act(() => update(n => n * 100));
// The new state is eagerly computed.
expect(ReactTestRenderer).toHaveYielded(['Compute state (1 -> 100)']);
@@ -839,9 +886,11 @@ describe('ReactHooks', () => {
class Cls extends React.Component {
render() {
- _setState(() => {
- ReactCurrentDispatcher.current.readContext(ThemeContext);
- });
+ act(() =>
+ _setState(() => {
+ ReactCurrentDispatcher.current.readContext(ThemeContext);
+ }),
+ );
return null;
}
}
@@ -853,7 +902,13 @@ describe('ReactHooks', () => {
,
),
- ).toWarnDev('Context can only be read while React is rendering');
+ ).toWarnDev(
+ [
+ 'Context can only be read while React is rendering',
+ 'Render methods should be a pure function of props and state',
+ ],
+ {withoutStack: 1},
+ );
});
it('warns when calling hooks inside useReducer', () => {
@@ -1294,9 +1349,11 @@ describe('ReactHooks', () => {
}
function B() {
- _setState(() => {
- throw new Error('Hello');
- });
+ act(() =>
+ _setState(() => {
+ throw new Error('Hello');
+ }),
+ );
return null;
}
diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
index f5a08478d995c..446dd1bf7e78d 100644
--- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
@@ -26,6 +26,7 @@ let useRef;
let useImperativeHandle;
let forwardRef;
let memo;
+let act;
// These tests use React Noop Renderer. All new tests should use React Test
// Renderer and go in ReactHooks-test; plan is gradually migrate the noop tests
@@ -50,6 +51,7 @@ describe('ReactHooksWithNoopRenderer', () => {
useImperativeHandle = React.useImperativeHandle;
forwardRef = React.forwardRef;
memo = React.memo;
+ act = ReactNoop.act;
});
function span(prop) {
@@ -76,8 +78,11 @@ describe('ReactHooksWithNoopRenderer', () => {
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
// Schedule some updates
- counter.current.updateCount(1);
- counter.current.updateCount(count => count + 10);
+ act(() => {
+ counter.current.updateCount(1);
+ counter.current.updateCount(count => count + 10);
+ });
+
// Partially flush without committing
ReactNoop.flushThrough(['Count: 11']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
@@ -157,11 +162,11 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
- counter.current.updateCount(1);
+ act(() => counter.current.updateCount(1));
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
- counter.current.updateCount(count => count + 10);
+ act(() => counter.current.updateCount(count => count + 10));
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
});
@@ -181,7 +186,7 @@ describe('ReactHooksWithNoopRenderer', () => {
expect(ReactNoop.flush()).toEqual(['getInitialState', 'Count: 42']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]);
- counter.current.updateCount(7);
+ act(() => counter.current.updateCount(7));
expect(ReactNoop.flush()).toEqual(['Count: 7']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]);
});
@@ -199,10 +204,10 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
- counter.current.updateCount(7);
+ act(() => counter.current.updateCount(7));
expect(ReactNoop.flush()).toEqual(['Count: 7']);
- counter.current.updateLabel('Total');
+ act(() => counter.current.updateLabel('Total'));
expect(ReactNoop.flush()).toEqual(['Total: 7']);
});
@@ -217,11 +222,11 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
- updaters[0](1);
+ act(() => updaters[0](1));
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
- updaters[0](count => count + 10);
+ act(() => updaters[0](count => count + 10));
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
@@ -240,7 +245,7 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flush();
ReactNoop.render(null);
ReactNoop.flush();
- expect(() => _updateCount(1)).toWarnDev(
+ expect(() => act(() => _updateCount(1))).toWarnDev(
"Warning: Can't perform a React state update on an unmounted " +
'component. This is a no-op, but it indicates a memory leak in your ' +
'application. To fix, cancel all subscriptions and asynchronous ' +
@@ -266,7 +271,7 @@ describe('ReactHooksWithNoopRenderer', () => {
expect(ReactNoop.flush()).toEqual([]);
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
- _updateCount(1);
+ act(() => _updateCount(1));
expect(ReactNoop.flush()).toEqual(['Count: 1']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
});
@@ -490,13 +495,15 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
- counter.current.dispatch(INCREMENT);
+ act(() => counter.current.dispatch(INCREMENT));
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
+ act(() => {
+ counter.current.dispatch(DECREMENT);
+ counter.current.dispatch(DECREMENT);
+ counter.current.dispatch(DECREMENT);
+ });
- counter.current.dispatch(DECREMENT);
- counter.current.dispatch(DECREMENT);
- counter.current.dispatch(DECREMENT);
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]);
});
@@ -530,13 +537,16 @@ describe('ReactHooksWithNoopRenderer', () => {
expect(ReactNoop.flush()).toEqual(['Init', 'Count: 10']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]);
- counter.current.dispatch(INCREMENT);
+ act(() => counter.current.dispatch(INCREMENT));
expect(ReactNoop.flush()).toEqual(['Count: 11']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
- counter.current.dispatch(DECREMENT);
- counter.current.dispatch(DECREMENT);
- counter.current.dispatch(DECREMENT);
+ act(() => {
+ counter.current.dispatch(DECREMENT);
+ counter.current.dispatch(DECREMENT);
+ counter.current.dispatch(DECREMENT);
+ });
+
expect(ReactNoop.flush()).toEqual(['Count: 8']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]);
});
@@ -562,9 +572,12 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flush();
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
- counter.current.dispatch(INCREMENT);
- counter.current.dispatch(INCREMENT);
- counter.current.dispatch(INCREMENT);
+ act(() => {
+ counter.current.dispatch(INCREMENT);
+ counter.current.dispatch(INCREMENT);
+ counter.current.dispatch(INCREMENT);
+ });
+
ReactNoop.flushSync(() => {
counter.current.dispatch(INCREMENT);
});
@@ -647,15 +660,20 @@ describe('ReactHooksWithNoopRenderer', () => {
});
return ;
}
+
ReactNoop.render([, ]);
- expect(ReactNoop.flush()).toEqual([
- 'Passive',
- 'Layout',
- 'Layout effect 0',
- 'Passive effect',
- 'Layout',
- 'Layout effect 1',
- ]);
+
+ act(() => {
+ expect(ReactNoop.flush()).toEqual([
+ 'Passive',
+ 'Layout',
+ 'Layout effect 0',
+ 'Passive effect',
+ 'Layout',
+ 'Layout effect 1',
+ ]);
+ });
+
expect(ReactNoop.getChildren()).toEqual([
span('Passive'),
span('Layout'),
@@ -779,7 +797,10 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.flushThrough(['Schedule update [0]', 'Count: 0']);
expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
- expect(ReactNoop.flush()).toEqual([]);
+ ReactNoop.batchedUpdates(() => {
+ expect(ReactNoop.flush()).toEqual([]);
+ });
+
expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
ReactNoop.flushPassiveEffects();
@@ -805,7 +826,7 @@ describe('ReactHooksWithNoopRenderer', () => {
// Enqueuing this update forces the passive effect to be flushed --
// updateCount(1) happens first, so 2 wins.
- _updateCount(2);
+ act(() => _updateCount(2));
expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
});
@@ -851,7 +872,7 @@ describe('ReactHooksWithNoopRenderer', () => {
// Enqueuing this update forces the passive effect to be flushed --
// updateCount(1) happens first, so 2 wins.
- _updateCount(2);
+ act(() => _updateCount(2));
expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
@@ -1368,7 +1389,7 @@ describe('ReactHooksWithNoopRenderer', () => {
span('Count: 0'),
]);
- button.current.increment();
+ act(button.current.increment);
expect(ReactNoop.flush()).toEqual([
// Button should not re-render, because its props haven't changed
// 'Increment',
@@ -1392,7 +1413,7 @@ describe('ReactHooksWithNoopRenderer', () => {
]);
// Callback should have updated
- button.current.increment();
+ act(button.current.increment);
expect(ReactNoop.flush()).toEqual(['Count: 11']);
expect(ReactNoop.getChildren()).toEqual([
span('Increment'),
@@ -1600,8 +1621,11 @@ describe('ReactHooksWithNoopRenderer', () => {
span('A: 0, B: 0, C: [not loaded]'),
]);
- updateA(2);
- updateB(3);
+ act(() => {
+ updateA(2);
+ updateB(3);
+ });
+
expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
expect(ReactNoop.getChildren()).toEqual([
span('A: 2, B: 3, C: [not loaded]'),
@@ -1646,10 +1670,11 @@ describe('ReactHooksWithNoopRenderer', () => {
ReactNoop.render();
expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: 0']);
expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]);
-
- updateA(2);
- updateB(3);
- updateC(4);
+ act(() => {
+ updateA(2);
+ updateB(3);
+ updateC(4);
+ });
expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']);
expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]);
ReactNoop.render();
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
index 73a55370a187c..69e4135cc70ed 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
@@ -3,6 +3,7 @@ let ReactTestRenderer;
let ReactFeatureFlags;
let ReactCache;
let Suspense;
+let act;
// let JestReact;
@@ -19,6 +20,7 @@ describe('ReactSuspense', () => {
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
ReactTestRenderer = require('react-test-renderer');
+ act = ReactTestRenderer.act;
// JestReact = require('jest-react');
ReactCache = require('react-cache');
@@ -797,7 +799,7 @@ describe('ReactSuspense', () => {
]);
expect(root).toMatchRenderedOutput('Tab: 0 + sibling');
- setTab(1);
+ act(() => setTab(1));
expect(ReactTestRenderer).toHaveYielded([
'Suspend! [Tab: 1]',
' + sibling',
@@ -811,7 +813,7 @@ describe('ReactSuspense', () => {
]);
expect(root).toMatchRenderedOutput('Tab: 1 + sibling');
- setTab(2);
+ act(() => setTab(2));
expect(ReactTestRenderer).toHaveYielded([
'Suspend! [Tab: 2]',
' + sibling',
@@ -864,7 +866,7 @@ describe('ReactSuspense', () => {
]);
expect(root).toMatchRenderedOutput('A:0');
- setStep(1);
+ act(() => setStep(1));
expect(ReactTestRenderer).toHaveYielded(['Suspend! [A:1]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
index ed3d9322ba1c4..9444bdbf6f019 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
@@ -158,7 +158,7 @@ describe('ReactSuspenseFuzz', () => {
if ((elapsedTime += 1000) > 1000000) {
throw new Error('Something did not resolve properly.');
}
- jest.advanceTimersByTime(1000);
+ ReactTestRenderer.act(() => jest.advanceTimersByTime(1000));
root.unstable_flushAll();
}
diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js
index fda60e171f460..e3b911f95843a 100644
--- a/packages/react-test-renderer/src/ReactTestRenderer.js
+++ b/packages/react-test-renderer/src/ReactTestRenderer.js
@@ -17,8 +17,8 @@ import {
updateContainer,
flushSync,
injectIntoDevTools,
+ batchedUpdates,
} from 'react-reconciler/inline.test';
-import {batchedUpdates} from 'events/ReactGenericBatching';
import {findCurrentFiberUsingSlowPath} from 'react-reconciler/reflection';
import {
Fragment,
@@ -39,6 +39,7 @@ import {
} from 'shared/ReactWorkTags';
import invariant from 'shared/invariant';
import ReactVersion from 'shared/ReactVersion';
+import warningWithoutStack from 'shared/warningWithoutStack';
import {getPublicInstance} from './ReactTestHostConfig';
import {
@@ -70,6 +71,11 @@ type FindOptions = $Shape<{
export type Predicate = (node: ReactTestInstance) => ?boolean;
+// for .act's return value
+type Thenable = {
+ then(resolve: () => mixed, reject?: () => mixed): mixed,
+};
+
const defaultTestOptions = {
createNodeMock: function() {
return null;
@@ -557,8 +563,61 @@ const ReactTestRendererFiber = {
/* eslint-enable camelcase */
unstable_setNowImplementation: setNowImplementation,
+
+ act(callback: () => void): Thenable {
+ // note: keep these warning messages in sync with
+ // createNoop.js and ReactTestUtils.js
+ let result = batchedUpdates(callback);
+ if (__DEV__) {
+ if (result !== undefined) {
+ let addendum;
+ if (typeof result.then === 'function') {
+ addendum =
+ "\n\nIt looks like you wrote TestRenderer.act(async () => ...) or returned a Promise from it's callback. " +
+ 'Putting asynchronous logic inside TestRenderer.act(...) is not supported.\n';
+ } else {
+ addendum = ' You returned: ' + result;
+ }
+ warningWithoutStack(
+ false,
+ 'The callback passed to TestRenderer.act(...) function must not return anything.%s',
+ addendum,
+ );
+ }
+ }
+ flushPassiveEffects();
+ // we want the user to not expect a return,
+ // but we want to warn if they use it like they can await on it.
+ return {
+ then() {
+ if (__DEV__) {
+ warningWithoutStack(
+ false,
+ 'Do not await the result of calling TestRenderer.act(...), it is not a Promise.',
+ );
+ }
+ },
+ };
+ },
};
+// root used to flush effects during .act() calls
+const actRoot = createContainer(
+ {
+ children: [],
+ createNodeMock: defaultTestOptions.createNodeMock,
+ tag: 'CONTAINER',
+ },
+ true,
+ false,
+);
+
+function flushPassiveEffects() {
+ // Trick to flush passive effects without exposing an internal API:
+ // Create a throwaway root and schedule a dummy update on it.
+ updateContainer(null, actRoot, null, null);
+}
+
const fiberToWrapper = new WeakMap();
function wrapFiber(fiber: Fiber): ReactTestInstance {
let wrapper = fiberToWrapper.get(fiber);
diff --git a/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js b/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
index 8240fbe75a8a4..0ca6fb54b50a9 100644
--- a/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
+++ b/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
@@ -1021,4 +1021,27 @@ describe('ReactTestRenderer', () => {
ReactNoop.flush();
ReactTestRenderer.create();
});
+
+ describe('act', () => {
+ it('works', () => {
+ function App(props) {
+ React.useEffect(() => {
+ props.callback();
+ });
+ return null;
+ }
+ let called = false;
+ ReactTestRenderer.act(() => {
+ ReactTestRenderer.create(
+ {
+ called = true;
+ }}
+ />,
+ );
+ });
+
+ expect(called).toBe(true);
+ });
+ });
});