expose TestUtils.act() for batching actions in tests (#14744)

* expose unstable_interact for batching actions in tests

* move to TestUtils

* move it all into testutils

* s/interact/act

* warn when calling hook-like setState outside batching mode

* pass tests

* merge-temp

* move jsdom test to callsite

* mark failing tests

* pass most tests (except one)

* augh IE

* pass fuzz tests

* better warning, expose the right batchedUpdates on TestRenderer for www

* move it into hooks, test for dom

* expose a flag on the host config, move stuff around

* rename, pass flow

* pass flow... again

* tweak .act() type

* enable for all jest environments/renderers; pass (most) tests.

* pass all tests

* expose just the warning from the scheduler

* don't return values

* a bunch of changes.

can't return values from .act
don't try to await .act calls
pass tests

* fixes and nits

* "fire events that udpates state"

* nit

* 🙄

* my bad

* hi andrew

(prettier fix)
This commit is contained in:
Sunil Pai
2019-02-05 16:10:16 +00:00
committed by GitHub
parent ad337120a3
commit ceed651092
13 changed files with 583 additions and 112 deletions
@@ -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, []);
@@ -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(<App />, container);
act(() => {
ReactDOM.render(<App />, container);
});
expect(container.innerHTML).toEqual(
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
);
setIsVisible(true);
act(() => setIsVisible(true));
expect(container.innerHTML).toEqual(
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
);
+166
View File
@@ -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(<Component />);
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(
<App
callback={() => {
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 (
<button id="button" onClick={() => setCtr(x => x + 1)}>
click me!
</button>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
let calledCtr = 0;
act(() => {
ReactDOM.render(
<App
callback={val => {
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 (
<button id="button" onClick={() => setCtr(x => x + 1)}>
{ctr}
</button>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
let button;
act(() => {
ReactDOM.render(<App />, 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 (
<button id="button" onClick={() => setValue(2)}>
{value}
</button>
);
}
const container = document.createElement('div');
document.body.appendChild(container);
let button;
act(() => {
ReactDOM.render(<App />, 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(<App />, 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},
);
});
});
+46
View File
@@ -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(<div />, 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.',
);
}
},
};
},
};
/**
+43
View File
@@ -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);
+19
View File
@@ -30,6 +30,7 @@ import {
} from './ReactHookEffectTags';
import {
scheduleWork,
warnIfNotCurrentlyBatchingInDev,
computeExpirationForFiber,
flushPassiveEffects,
requestCurrentTime,
@@ -1003,6 +1004,19 @@ function updateMemo<T>(
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<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
@@ -1121,6 +1135,11 @@ function dispatchAction<S, A>(
}
}
}
if (__DEV__) {
if (shouldWarnForUnbatchedSetState === true) {
warnIfNotCurrentlyBatchingInDev(fiber);
}
}
scheduleWork(fiber, expirationTime);
}
}
+19
View File
@@ -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) {
@@ -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(<Parent theme="dark" />);
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(<Parent theme="dark" />);
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', () => {
<Cls />
</React.Fragment>,
),
).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;
}
@@ -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 <Text text="Layout" />;
}
ReactNoop.render([<PassiveEffect key="p" />, <LayoutEffect key="l" />]);
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(<App loadC={true} />);
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(<App loadC={false} />);
@@ -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...');
@@ -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();
}
+60 -1
View File
@@ -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);
@@ -1021,4 +1021,27 @@ describe('ReactTestRenderer', () => {
ReactNoop.flush();
ReactTestRenderer.create(<App />);
});
describe('act', () => {
it('works', () => {
function App(props) {
React.useEffect(() => {
props.callback();
});
return null;
}
let called = false;
ReactTestRenderer.act(() => {
ReactTestRenderer.create(
<App
callback={() => {
called = true;
}}
/>,
);
});
expect(called).toBe(true);
});
});
});