Skip to content

Commit 267ed98

Browse files
author
Sunil Pai
authored
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)
1 parent fb3f7bf commit 267ed98

13 files changed

+583
-112
lines changed

packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
let React;
1414
let ReactTestRenderer;
1515
let ReactDebugTools;
16+
let act;
1617

17-
describe('ReactHooksInspectionIntergration', () => {
18+
describe('ReactHooksInspectionIntegration', () => {
1819
beforeEach(() => {
1920
jest.resetModules();
2021
React = require('react');
2122
ReactTestRenderer = require('react-test-renderer');
23+
act = ReactTestRenderer.act;
2224
ReactDebugTools = require('react-debug-tools');
2325
});
2426

@@ -47,7 +49,7 @@ describe('ReactHooksInspectionIntergration', () => {
4749
onMouseUp: setStateB,
4850
} = renderer.root.findByType('div').props;
4951

50-
setStateA('Hi');
52+
act(() => setStateA('Hi'));
5153

5254
childFiber = renderer.root.findByType(Foo)._currentFiber();
5355
tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -57,7 +59,7 @@ describe('ReactHooksInspectionIntergration', () => {
5759
{name: 'State', value: 'world', subHooks: []},
5860
]);
5961

60-
setStateB('world!');
62+
act(() => setStateB('world!'));
6163

6264
childFiber = renderer.root.findByType(Foo)._currentFiber();
6365
tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -91,8 +93,12 @@ describe('ReactHooksInspectionIntergration', () => {
9193
React.useMemo(() => state1 + state2, [state1]);
9294

9395
function update() {
94-
setState('A');
95-
dispatch({value: 'B'});
96+
act(() => {
97+
setState('A');
98+
});
99+
act(() => {
100+
dispatch({value: 'B'});
101+
});
96102
ref.current = 'C';
97103
}
98104
let memoizedUpdate = React.useCallback(update, []);

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

+8-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ let React;
1313
let ReactDOM;
1414
let Suspense;
1515
let ReactCache;
16+
let ReactTestUtils;
1617
let TextResource;
18+
let act;
1719

1820
describe('ReactDOMSuspensePlaceholder', () => {
1921
let container;
@@ -23,6 +25,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
2325
React = require('react');
2426
ReactDOM = require('react-dom');
2527
ReactCache = require('react-cache');
28+
ReactTestUtils = require('react-dom/test-utils');
29+
act = ReactTestUtils.act;
2630
Suspense = React.Suspense;
2731
container = document.createElement('div');
2832
document.body.appendChild(container);
@@ -142,12 +146,14 @@ describe('ReactDOMSuspensePlaceholder', () => {
142146
);
143147
}
144148

145-
ReactDOM.render(<App />, container);
149+
act(() => {
150+
ReactDOM.render(<App />, container);
151+
});
146152
expect(container.innerHTML).toEqual(
147153
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
148154
);
149155

150-
setIsVisible(true);
156+
act(() => setIsVisible(true));
151157
expect(container.innerHTML).toEqual(
152158
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
153159
);

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

+166
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let React;
1414
let ReactDOM;
1515
let ReactDOMServer;
1616
let ReactTestUtils;
17+
let act;
1718

1819
function getTestDocument(markup) {
1920
const doc = document.implementation.createHTMLDocument('');
@@ -33,6 +34,7 @@ describe('ReactTestUtils', () => {
3334
ReactDOM = require('react-dom');
3435
ReactDOMServer = require('react-dom/server');
3536
ReactTestUtils = require('react-dom/test-utils');
37+
act = ReactTestUtils.act;
3638
});
3739

3840
it('Simulate should have locally attached media events', () => {
@@ -515,4 +517,168 @@ describe('ReactTestUtils', () => {
515517
ReactTestUtils.renderIntoDocument(<Component />);
516518
expect(mockArgs.length).toEqual(0);
517519
});
520+
521+
it('can use act to batch effects', () => {
522+
function App(props) {
523+
React.useEffect(props.callback);
524+
return null;
525+
}
526+
const container = document.createElement('div');
527+
document.body.appendChild(container);
528+
529+
try {
530+
let called = false;
531+
act(() => {
532+
ReactDOM.render(
533+
<App
534+
callback={() => {
535+
called = true;
536+
}}
537+
/>,
538+
container,
539+
);
540+
});
541+
542+
expect(called).toBe(true);
543+
} finally {
544+
document.body.removeChild(container);
545+
}
546+
});
547+
548+
it('flushes effects on every call', () => {
549+
function App(props) {
550+
let [ctr, setCtr] = React.useState(0);
551+
React.useEffect(() => {
552+
props.callback(ctr);
553+
});
554+
return (
555+
<button id="button" onClick={() => setCtr(x => x + 1)}>
556+
click me!
557+
</button>
558+
);
559+
}
560+
561+
const container = document.createElement('div');
562+
document.body.appendChild(container);
563+
let calledCtr = 0;
564+
act(() => {
565+
ReactDOM.render(
566+
<App
567+
callback={val => {
568+
calledCtr = val;
569+
}}
570+
/>,
571+
container,
572+
);
573+
});
574+
const button = document.getElementById('button');
575+
function click() {
576+
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
577+
}
578+
579+
act(() => {
580+
click();
581+
click();
582+
click();
583+
});
584+
expect(calledCtr).toBe(3);
585+
act(click);
586+
expect(calledCtr).toBe(4);
587+
act(click);
588+
expect(calledCtr).toBe(5);
589+
590+
document.body.removeChild(container);
591+
});
592+
593+
it('can use act to batch effects on updates too', () => {
594+
function App() {
595+
let [ctr, setCtr] = React.useState(0);
596+
return (
597+
<button id="button" onClick={() => setCtr(x => x + 1)}>
598+
{ctr}
599+
</button>
600+
);
601+
}
602+
const container = document.createElement('div');
603+
document.body.appendChild(container);
604+
let button;
605+
act(() => {
606+
ReactDOM.render(<App />, container);
607+
});
608+
button = document.getElementById('button');
609+
expect(button.innerHTML).toBe('0');
610+
act(() => {
611+
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
612+
});
613+
expect(button.innerHTML).toBe('1');
614+
document.body.removeChild(container);
615+
});
616+
617+
it('detects setState being called outside of act(...)', () => {
618+
let setValueRef = null;
619+
function App() {
620+
let [value, setValue] = React.useState(0);
621+
setValueRef = setValue;
622+
return (
623+
<button id="button" onClick={() => setValue(2)}>
624+
{value}
625+
</button>
626+
);
627+
}
628+
const container = document.createElement('div');
629+
document.body.appendChild(container);
630+
let button;
631+
act(() => {
632+
ReactDOM.render(<App />, container);
633+
button = container.querySelector('#button');
634+
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
635+
});
636+
expect(button.innerHTML).toBe('2');
637+
expect(() => setValueRef(1)).toWarnDev(
638+
['An update to App inside a test was not wrapped in act(...).'],
639+
{withoutStack: 1},
640+
);
641+
document.body.removeChild(container);
642+
});
643+
644+
it('lets a ticker update', () => {
645+
function App() {
646+
let [toggle, setToggle] = React.useState(0);
647+
React.useEffect(() => {
648+
let timeout = setTimeout(() => {
649+
setToggle(1);
650+
}, 200);
651+
return () => clearTimeout(timeout);
652+
});
653+
return toggle;
654+
}
655+
const container = document.createElement('div');
656+
657+
act(() => {
658+
act(() => {
659+
ReactDOM.render(<App />, container);
660+
});
661+
jest.advanceTimersByTime(250);
662+
});
663+
664+
expect(container.innerHTML).toBe('1');
665+
});
666+
667+
it('warns if you return a value inside act', () => {
668+
expect(() => act(() => 123)).toWarnDev(
669+
[
670+
'The callback passed to ReactTestUtils.act(...) function must not return anything.',
671+
],
672+
{withoutStack: true},
673+
);
674+
});
675+
676+
it('warns if you try to await an .act call', () => {
677+
expect(act(() => {}).then).toWarnDev(
678+
[
679+
'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
680+
],
681+
{withoutStack: true},
682+
);
683+
});
518684
});

packages/react-dom/src/test-utils/ReactTestUtils.js

+46
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ import {
1818
import SyntheticEvent from 'events/SyntheticEvent';
1919
import invariant from 'shared/invariant';
2020
import lowPriorityWarning from 'shared/lowPriorityWarning';
21+
import warningWithoutStack from 'shared/warningWithoutStack';
2122
import {ELEMENT_NODE} from '../shared/HTMLNodeType';
2223
import * as DOMTopLevelEventTypes from '../events/DOMTopLevelEventTypes';
2324

25+
// for .act's return value
26+
type Thenable = {
27+
then(resolve: () => mixed, reject?: () => mixed): mixed,
28+
};
29+
2430
const {findDOMNode} = ReactDOM;
2531
// Keep in sync with ReactDOMUnstableNativeDependencies.js
2632
// and ReactDOM.js:
@@ -145,6 +151,9 @@ function validateClassInstance(inst, methodName) {
145151
);
146152
}
147153

154+
// stub element used by act() when flushing effects
155+
let actContainerElement = document.createElement('div');
156+
148157
/**
149158
* Utilities for making it easy to test React components.
150159
*
@@ -380,6 +389,43 @@ const ReactTestUtils = {
380389

381390
Simulate: null,
382391
SimulateNative: {},
392+
393+
act(callback: () => void): Thenable {
394+
// note: keep these warning messages in sync with
395+
// createReactNoop.js and ReactTestRenderer.js
396+
const result = ReactDOM.unstable_batchedUpdates(callback);
397+
if (__DEV__) {
398+
if (result !== undefined) {
399+
let addendum;
400+
if (typeof result.then === 'function') {
401+
addendum =
402+
'\n\nIt looks like you wrote ReactTestUtils.act(async () => ...), ' +
403+
'or returned a Promise from the callback passed to it. ' +
404+
'Putting asynchronous logic inside ReactTestUtils.act(...) is not supported.\n';
405+
} else {
406+
addendum = ' You returned: ' + result;
407+
}
408+
warningWithoutStack(
409+
false,
410+
'The callback passed to ReactTestUtils.act(...) function must not return anything.%s',
411+
addendum,
412+
);
413+
}
414+
}
415+
ReactDOM.render(<div />, actContainerElement);
416+
// we want the user to not expect a return,
417+
// but we want to warn if they use it like they can await on it.
418+
return {
419+
then() {
420+
if (__DEV__) {
421+
warningWithoutStack(
422+
false,
423+
'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
424+
);
425+
}
426+
},
427+
};
428+
},
383429
};
384430

385431
/**

packages/react-noop-renderer/src/createReactNoop.js

+43
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import type {ReactNodeList} from 'shared/ReactTypes';
2121
import {createPortal} from 'shared/ReactPortal';
2222
import expect from 'expect';
2323
import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
24+
import warningWithoutStack from 'shared/warningWithoutStack';
25+
26+
// for .act's return value
27+
type Thenable = {
28+
then(resolve: () => mixed, reject?: () => mixed): mixed,
29+
};
2430

2531
type Container = {
2632
rootID: string,
@@ -864,6 +870,43 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
864870

865871
interactiveUpdates: NoopRenderer.interactiveUpdates,
866872

873+
// maybe this should exist only in the test file
874+
act(callback: () => void): Thenable {
875+
// note: keep these warning messages in sync with
876+
// ReactTestRenderer.js and ReactTestUtils.js
877+
let result = NoopRenderer.batchedUpdates(callback);
878+
if (__DEV__) {
879+
if (result !== undefined) {
880+
let addendum;
881+
if (typeof result.then === 'function') {
882+
addendum =
883+
"\n\nIt looks like you wrote ReactNoop.act(async () => ...) or returned a Promise from it's callback. " +
884+
'Putting asynchronous logic inside ReactNoop.act(...) is not supported.\n';
885+
} else {
886+
addendum = ' You returned: ' + result;
887+
}
888+
warningWithoutStack(
889+
false,
890+
'The callback passed to ReactNoop.act(...) function must not return anything.%s',
891+
addendum,
892+
);
893+
}
894+
}
895+
ReactNoop.flushPassiveEffects();
896+
// we want the user to not expect a return,
897+
// but we want to warn if they use it like they can await on it.
898+
return {
899+
then() {
900+
if (__DEV__) {
901+
warningWithoutStack(
902+
false,
903+
'Do not await the result of calling ReactNoop.act(...), it is not a Promise.',
904+
);
905+
}
906+
},
907+
};
908+
},
909+
867910
flushSync(fn: () => mixed) {
868911
yieldedValues = [];
869912
NoopRenderer.flushSync(fn);

0 commit comments

Comments
 (0)