@samitouri / QOS-React / commits / 267ed98146

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)

Sunil Pai committed Feb 5, 2019 at 16:10 UTC 267ed981466ef323b1461b013b5db8db31e31772
13 files changed +583 -112
packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+11 -5
@@ -13,12 +13,14 @@
13 let React;
14 let ReactTestRenderer;
15 let ReactDebugTools;
16 +let act;
17
17 -describe('ReactHooksInspectionIntergration', () => {
18 +describe('ReactHooksInspectionIntegration', () => {
19 beforeEach(() => {
20 jest.resetModules();
21 React = require('react');
22 ReactTestRenderer = require('react-test-renderer');
23 + act = ReactTestRenderer.act;
24 ReactDebugTools = require('react-debug-tools');
25 });
26
@@ -47,7 +49,7 @@ describe('ReactHooksInspectionIntergration', () => {
49 onMouseUp: setStateB,
50 } = renderer.root.findByType('div').props;
51
50 - setStateA('Hi');
52 + act(() => setStateA('Hi'));
53
54 childFiber = renderer.root.findByType(Foo)._currentFiber();
55 tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -57,7 +59,7 @@ describe('ReactHooksInspectionIntergration', () => {
59 {name: 'State', value: 'world', subHooks: []},
60 ]);
61
60 - setStateB('world!');
62 + act(() => setStateB('world!'));
63
64 childFiber = renderer.root.findByType(Foo)._currentFiber();
65 tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -91,8 +93,12 @@ describe('ReactHooksInspectionIntergration', () => {
93 React.useMemo(() => state1 + state2, [state1]);
94
95 function update() {
94 - setState('A');
95 - dispatch({value: 'B'});
96 + act(() => {
97 + setState('A');
98 + });
99 + act(() => {
100 + dispatch({value: 'B'});
101 + });
102 ref.current = 'C';
103 }
104 let memoizedUpdate = React.useCallback(update, []);
packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
+8 -2
@@ -13,7 +13,9 @@ let React;
13 let ReactDOM;
14 let Suspense;
15 let ReactCache;
16 +let ReactTestUtils;
17 let TextResource;
18 +let act;
19
20 describe('ReactDOMSuspensePlaceholder', () => {
21 let container;
@@ -23,6 +25,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
25 React = require('react');
26 ReactDOM = require('react-dom');
27 ReactCache = require('react-cache');
28 + ReactTestUtils = require('react-dom/test-utils');
29 + act = ReactTestUtils.act;
30 Suspense = React.Suspense;
31 container = document.createElement('div');
32 document.body.appendChild(container);
@@ -142,12 +146,14 @@ describe('ReactDOMSuspensePlaceholder', () => {
146 );
147 }
148
145 - ReactDOM.render(<App />, container);
149 + act(() => {
150 + ReactDOM.render(<App />, container);
151 + });
152 expect(container.innerHTML).toEqual(
153 '<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
154 );
155
150 - setIsVisible(true);
156 + act(() => setIsVisible(true));
157 expect(container.innerHTML).toEqual(
158 '<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
159 );
packages/react-dom/src/__tests__/ReactTestUtils-test.js
+166
@@ -14,6 +14,7 @@ let React;
14 let ReactDOM;
15 let ReactDOMServer;
16 let ReactTestUtils;
17 +let act;
18
19 function getTestDocument(markup) {
20 const doc = document.implementation.createHTMLDocument('');
@@ -33,6 +34,7 @@ describe('ReactTestUtils', () => {
34 ReactDOM = require('react-dom');
35 ReactDOMServer = require('react-dom/server');
36 ReactTestUtils = require('react-dom/test-utils');
37 + act = ReactTestUtils.act;
38 });
39
40 it('Simulate should have locally attached media events', () => {
@@ -515,4 +517,168 @@ describe('ReactTestUtils', () => {
517 ReactTestUtils.renderIntoDocument(<Component />);
518 expect(mockArgs.length).toEqual(0);
519 });
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 + });
684 });
packages/react-dom/src/test-utils/ReactTestUtils.js
+46
@@ -18,9 +18,15 @@ import {
18 import SyntheticEvent from 'events/SyntheticEvent';
19 import invariant from 'shared/invariant';
20 import lowPriorityWarning from 'shared/lowPriorityWarning';
21 +import warningWithoutStack from 'shared/warningWithoutStack';
22 import {ELEMENT_NODE} from '../shared/HTMLNodeType';
23 import * as DOMTopLevelEventTypes from '../events/DOMTopLevelEventTypes';
24
25 +// for .act's return value
26 +type Thenable = {
27 + then(resolve: () => mixed, reject?: () => mixed): mixed,
28 +};
29 +
30 const {findDOMNode} = ReactDOM;
31 // Keep in sync with ReactDOMUnstableNativeDependencies.js
32 // and ReactDOM.js:
@@ -145,6 +151,9 @@ function validateClassInstance(inst, methodName) {
151 );
152 }
153
154 +// stub element used by act() when flushing effects
155 +let actContainerElement = document.createElement('div');
156 +
157 /**
158 * Utilities for making it easy to test React components.
159 *
@@ -380,6 +389,43 @@ const ReactTestUtils = {
389
390 Simulate: null,
391 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 + },
429 };
430
431 /**
packages/react-noop-renderer/src/createReactNoop.js
+43
@@ -21,6 +21,12 @@ import type {ReactNodeList} from 'shared/ReactTypes';
21 import {createPortal} from 'shared/ReactPortal';
22 import expect from 'expect';
23 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 +};
30
31 type Container = {
32 rootID: string,
@@ -864,6 +870,43 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
870
871 interactiveUpdates: NoopRenderer.interactiveUpdates,
872
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 +
910 flushSync(fn: () => mixed) {
911 yieldedValues = [];
912 NoopRenderer.flushSync(fn);
packages/react-reconciler/src/ReactFiberHooks.js
+19
@@ -30,6 +30,7 @@ import {
30 } from './ReactHookEffectTags';
31 import {
32 scheduleWork,
33 + warnIfNotCurrentlyBatchingInDev,
34 computeExpirationForFiber,
35 flushPassiveEffects,
36 requestCurrentTime,
@@ -1003,6 +1004,19 @@ function updateMemo<T>(
1004 return nextValue;
1005 }
1006
1007 +// in a test-like environment, we want to warn if dispatchAction()
1008 +// is called outside of a batchedUpdates/TestUtils.act(...) call.
1009 +let shouldWarnForUnbatchedSetState = false;
1010 +
1011 +if (__DEV__) {
1012 + // jest isnt' a 'global', it's just exposed to tests via a wrapped function
1013 + // further, this isn't a test file, so flow doesn't recognize the symbol. So...
1014 + // $FlowExpectedError - because requirements don't give a damn about your type sigs.
1015 + if ('undefined' !== typeof jest) {
1016 + shouldWarnForUnbatchedSetState = true;
1017 + }
1018 +}
1019 +
1020 function dispatchAction<S, A>(
1021 fiber: Fiber,
1022 queue: UpdateQueue<S, A>,
@@ -1121,6 +1135,11 @@ function dispatchAction<S, A>(
1135 }
1136 }
1137 }
1138 + if (__DEV__) {
1139 + if (shouldWarnForUnbatchedSetState === true) {
1140 + warnIfNotCurrentlyBatchingInDev(fiber);
1141 + }
1142 + }
1143 scheduleWork(fiber, expirationTime);
1144 }
1145 }
packages/react-reconciler/src/ReactFiberScheduler.js
+19
@@ -1790,6 +1790,25 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
1790 return root;
1791 }
1792
1793 +export function warnIfNotCurrentlyBatchingInDev(fiber: Fiber): void {
1794 + if (__DEV__) {
1795 + if (isRendering === false && isBatchingUpdates === false) {
1796 + warningWithoutStack(
1797 + false,
1798 + 'An update to %s inside a test was not wrapped in act(...).\n\n' +
1799 + 'When testing, code that causes React state updates should be wrapped into act(...):\n\n' +
1800 + 'act(() => {\n' +
1801 + ' /* fire events that update state */\n' +
1802 + '});\n' +
1803 + '/* assert on the output */\n\n' +
1804 + "This ensures that you're testing the behavior the user would see in the browser." +
1805 + ' Learn more at https://fb.me/react-wrap-tests-with-act',
1806 + getComponentName(fiber.type),
1807 + );
1808 + }
1809 + }
1810 +}
1811 +
1812 function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1813 const root = scheduleWorkToRoot(fiber, expirationTime);
1814 if (root === null) {
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+116 -59
@@ -16,6 +16,7 @@ let React;
16 let ReactFeatureFlags;
17 let ReactTestRenderer;
18 let ReactDOMServer;
19 +let act;
20
21 // Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to
22 // gradually migrate those to this file.
@@ -28,6 +29,7 @@ describe('ReactHooks', () => {
29 React = require('react');
30 ReactTestRenderer = require('react-test-renderer');
31 ReactDOMServer = require('react-dom/server');
32 + act = ReactTestRenderer.act;
33 });
34
35 if (__DEV__) {
@@ -81,8 +83,11 @@ describe('ReactHooks', () => {
83 expect(root).toMatchRenderedOutput('0, 0');
84
85 // Normal update
84 - setCounter1(1);
85 - setCounter2(1);
86 + act(() => {
87 + setCounter1(1);
88 + setCounter2(1);
89 + });
90 +
91 expect(root).toFlushAndYield([
92 'Parent: 1, 1',
93 'Child: 1, 1',
@@ -90,13 +95,16 @@ describe('ReactHooks', () => {
95 ]);
96
97 // Update that bails out.
93 - setCounter1(1);
98 + act(() => setCounter1(1));
99 expect(root).toFlushAndYield(['Parent: 1, 1']);
100
101 // This time, one of the state updates but the other one doesn't. So we
102 // can't bail out.
98 - setCounter1(1);
99 - setCounter2(2);
103 + act(() => {
104 + setCounter1(1);
105 + setCounter2(2);
106 + });
107 +
108 expect(root).toFlushAndYield([
109 'Parent: 1, 2',
110 'Child: 1, 2',
@@ -104,20 +112,24 @@ describe('ReactHooks', () => {
112 ]);
113
114 // Lots of updates that eventually resolve to the current values.
107 - setCounter1(9);
108 - setCounter2(3);
109 - setCounter1(4);
110 - setCounter2(7);
111 - setCounter1(1);
112 - setCounter2(2);
115 + act(() => {
116 + setCounter1(9);
117 + setCounter2(3);
118 + setCounter1(4);
119 + setCounter2(7);
120 + setCounter1(1);
121 + setCounter2(2);
122 + });
123
124 // Because the final values are the same as the current values, the
125 // component bails out.
126 expect(root).toFlushAndYield(['Parent: 1, 2']);
127
128 // prepare to check SameValue
119 - setCounter1(0 / -1);
120 - setCounter2(NaN);
129 + act(() => {
130 + setCounter1(0 / -1);
131 + setCounter2(NaN);
132 + });
133 expect(root).toFlushAndYield([
134 'Parent: 0, NaN',
135 'Child: 0, NaN',
@@ -125,14 +137,19 @@ describe('ReactHooks', () => {
137 ]);
138
139 // check if re-setting to negative 0 / NaN still bails out
128 - setCounter1(0 / -1);
129 - setCounter2(NaN);
130 - setCounter2(Infinity);
131 - setCounter2(NaN);
140 + act(() => {
141 + setCounter1(0 / -1);
142 + setCounter2(NaN);
143 + setCounter2(Infinity);
144 + setCounter2(NaN);
145 + });
146 +
147 expect(root).toFlushAndYield(['Parent: 0, NaN']);
148
149 // check if changing negative 0 to positive 0 does not bail out
135 - setCounter1(0);
150 + act(() => {
151 + setCounter1(0);
152 + });
153 expect(root).toFlushAndYield([
154 'Parent: 0, NaN',
155 'Child: 0, NaN',
@@ -172,21 +189,27 @@ describe('ReactHooks', () => {
189 expect(root).toMatchRenderedOutput('0, 0 (light)');
190
191 // Normal update
175 - setCounter1(1);
176 - setCounter2(1);
192 + act(() => {
193 + setCounter1(1);
194 + setCounter2(1);
195 + });
196 +
197 expect(root).toFlushAndYield([
198 'Parent: 1, 1 (light)',
199 'Child: 1, 1 (light)',
200 ]);
201
202 // Update that bails out.
183 - setCounter1(1);
203 + act(() => setCounter1(1));
204 expect(root).toFlushAndYield(['Parent: 1, 1 (light)']);
205
206 // This time, one of the state updates but the other one doesn't. So we
207 // can't bail out.
188 - setCounter1(1);
189 - setCounter2(2);
208 + act(() => {
209 + setCounter1(1);
210 + setCounter2(2);
211 + });
212 +
213 expect(root).toFlushAndYield([
214 'Parent: 1, 2 (light)',
215 'Child: 1, 2 (light)',
@@ -194,14 +217,20 @@ describe('ReactHooks', () => {
217
218 // Updates bail out, but component still renders because props
219 // have changed
197 - setCounter1(1);
198 - setCounter2(2);
220 + act(() => {
221 + setCounter1(1);
222 + setCounter2(2);
223 + });
224 +
225 root.update(<Parent theme="dark" />);
226 expect(root).toFlushAndYield(['Parent: 1, 2 (dark)', 'Child: 1, 2 (dark)']);
227
228 // Both props and state bail out
203 - setCounter1(1);
204 - setCounter2(2);
229 + act(() => {
230 + setCounter1(1);
231 + setCounter2(2);
232 + });
233 +
234 root.update(<Parent theme="dark" />);
235 expect(root).toFlushAndYield(['Parent: 1, 2 (dark)']);
236 });
@@ -224,9 +253,11 @@ describe('ReactHooks', () => {
253 expect(root).toMatchRenderedOutput('0');
254
255 expect(() => {
227 - setCounter(1, () => {
228 - throw new Error('Expected to ignore the callback.');
229 - });
256 + act(() =>
257 + setCounter(1, () => {
258 + throw new Error('Expected to ignore the callback.');
259 + }),
260 + );
261 }).toWarnDev(
262 'State updates from the useState() and useReducer() Hooks ' +
263 "don't support the second callback argument. " +
@@ -256,9 +287,11 @@ describe('ReactHooks', () => {
287 expect(root).toMatchRenderedOutput('0');
288
289 expect(() => {
259 - dispatch(1, () => {
260 - throw new Error('Expected to ignore the callback.');
261 - });
290 + act(() =>
291 + dispatch(1, () => {
292 + throw new Error('Expected to ignore the callback.');
293 + }),
294 + );
295 }).toWarnDev(
296 'State updates from the useState() and useReducer() Hooks ' +
297 "don't support the second callback argument. " +
@@ -326,7 +359,7 @@ describe('ReactHooks', () => {
359 expect(root).toMatchRenderedOutput('0 (light)');
360
361 // Normal update
329 - setCounter(1);
362 + act(() => setCounter(1));
363 expect(root).toFlushAndYield([
364 'Parent: 1 (light)',
365 'Child: 1 (light)',
@@ -335,14 +368,17 @@ describe('ReactHooks', () => {
368 expect(root).toMatchRenderedOutput('1 (light)');
369
370 // Update that doesn't change state, so it bails out
338 - setCounter(1);
371 + act(() => setCounter(1));
372 expect(root).toFlushAndYield(['Parent: 1 (light)']);
373 expect(root).toMatchRenderedOutput('1 (light)');
374
375 // Update that doesn't change state, but the context changes, too, so it
376 // can't bail out
344 - setCounter(1);
345 - setTheme('dark');
377 + act(() => {
378 + setCounter(1);
379 + setTheme('dark');
380 + });
381 +
382 expect(root).toFlushAndYield([
383 'Theme: dark',
384 'Parent: 1 (dark)',
@@ -377,7 +413,7 @@ describe('ReactHooks', () => {
413 expect(root).toMatchRenderedOutput('0');
414
415 // Normal update
380 - setCounter(1);
416 + act(() => setCounter(1));
417 expect(root).toFlushAndYield(['Parent: 1', 'Child: 1', 'Effect: 1']);
418 expect(root).toMatchRenderedOutput('1');
419
@@ -385,38 +421,47 @@ describe('ReactHooks', () => {
421 // because the alterate fiber has pending update priority, so we have to
422 // enter the render phase before we can bail out. But we bail out before
423 // rendering the child, and we don't fire any effects.
388 - setCounter(1);
424 + act(() => setCounter(1));
425 expect(root).toFlushAndYield(['Parent: 1']);
426 expect(root).toMatchRenderedOutput('1');
427
428 // Update to the same state again. This times, neither fiber has pending
429 // update priority, so we can bail out before even entering the render phase.
394 - setCounter(1);
430 + act(() => setCounter(1));
431 expect(root).toFlushAndYield([]);
432 expect(root).toMatchRenderedOutput('1');
433
434 // This changes the state to something different so it renders normally.
399 - setCounter(2);
435 + act(() => setCounter(2));
436 expect(root).toFlushAndYield(['Parent: 2', 'Child: 2', 'Effect: 2']);
437 expect(root).toMatchRenderedOutput('2');
438
439 // prepare to check SameValue
404 - setCounter(0);
440 + act(() => {
441 + setCounter(0);
442 + });
443 expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
444 expect(root).toMatchRenderedOutput('0');
445
446 // Update to the same state for the first time to flush the queue
409 - setCounter(0);
447 + act(() => {
448 + setCounter(0);
449 + });
450 +
451 expect(root).toFlushAndYield(['Parent: 0']);
452 expect(root).toMatchRenderedOutput('0');
453
454 // Update again to the same state. Should bail out.
414 - setCounter(0);
455 + act(() => {
456 + setCounter(0);
457 + });
458 expect(root).toFlushAndYield([]);
459 expect(root).toMatchRenderedOutput('0');
460
461 // Update to a different state (positive 0 to negative 0)
419 - setCounter(0 / -1);
462 + act(() => {
463 + setCounter(0 / -1);
464 + });
465 expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
466 expect(root).toMatchRenderedOutput('0');
467 });
@@ -450,12 +495,14 @@ describe('ReactHooks', () => {
495 return value;
496 });
497 };
453 - update(0);
454 - update(0);
455 - update(0);
456 - update(1);
457 - update(2);
458 - update(3);
498 + act(() => {
499 + update(0);
500 + update(0);
501 + update(0);
502 + update(1);
503 + update(2);
504 + update(3);
505 + });
506
507 expect(ReactTestRenderer).toHaveYielded([
508 // The first four updates were eagerly computed, because the queue is
@@ -511,7 +558,7 @@ describe('ReactHooks', () => {
558 };
559
560 // Update at normal priority
514 - update(n => n * 100);
561 + act(() => update(n => n * 100));
562
563 // The new state is eagerly computed.
564 expect(ReactTestRenderer).toHaveYielded(['Compute state (1 -> 100)']);
@@ -839,9 +886,11 @@ describe('ReactHooks', () => {
886
887 class Cls extends React.Component {
888 render() {
842 - _setState(() => {
843 - ReactCurrentDispatcher.current.readContext(ThemeContext);
844 - });
889 + act(() =>
890 + _setState(() => {
891 + ReactCurrentDispatcher.current.readContext(ThemeContext);
892 + }),
893 + );
894 return null;
895 }
896 }
@@ -853,7 +902,13 @@ describe('ReactHooks', () => {
902 <Cls />
903 </React.Fragment>,
904 ),
856 - ).toWarnDev('Context can only be read while React is rendering');
905 + ).toWarnDev(
906 + [
907 + 'Context can only be read while React is rendering',
908 + 'Render methods should be a pure function of props and state',
909 + ],
910 + {withoutStack: 1},
911 + );
912 });
913
914 it('warns when calling hooks inside useReducer', () => {
@@ -1294,9 +1349,11 @@ describe('ReactHooks', () => {
1349 }
1350
1351 function B() {
1297 - _setState(() => {
1298 - throw new Error('Hello');
1299 - });
1352 + act(() =>
1353 + _setState(() => {
1354 + throw new Error('Hello');
1355 + }),
1356 + );
1357 return null;
1358 }
1359
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
+66 -41
@@ -26,6 +26,7 @@ let useRef;
26 let useImperativeHandle;
27 let forwardRef;
28 let memo;
29 +let act;
30
31 // These tests use React Noop Renderer. All new tests should use React Test
32 // Renderer and go in ReactHooks-test; plan is gradually migrate the noop tests
@@ -50,6 +51,7 @@ describe('ReactHooksWithNoopRenderer', () => {
51 useImperativeHandle = React.useImperativeHandle;
52 forwardRef = React.forwardRef;
53 memo = React.memo;
54 + act = ReactNoop.act;
55 });
56
57 function span(prop) {
@@ -76,8 +78,11 @@ describe('ReactHooksWithNoopRenderer', () => {
78 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
79
80 // Schedule some updates
79 - counter.current.updateCount(1);
80 - counter.current.updateCount(count => count + 10);
81 + act(() => {
82 + counter.current.updateCount(1);
83 + counter.current.updateCount(count => count + 10);
84 + });
85 +
86 // Partially flush without committing
87 ReactNoop.flushThrough(['Count: 11']);
88 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
@@ -157,11 +162,11 @@ describe('ReactHooksWithNoopRenderer', () => {
162 ReactNoop.flush();
163 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
164
160 - counter.current.updateCount(1);
165 + act(() => counter.current.updateCount(1));
166 ReactNoop.flush();
167 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
168
164 - counter.current.updateCount(count => count + 10);
169 + act(() => counter.current.updateCount(count => count + 10));
170 ReactNoop.flush();
171 expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
172 });
@@ -181,7 +186,7 @@ describe('ReactHooksWithNoopRenderer', () => {
186 expect(ReactNoop.flush()).toEqual(['getInitialState', 'Count: 42']);
187 expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]);
188
184 - counter.current.updateCount(7);
189 + act(() => counter.current.updateCount(7));
190 expect(ReactNoop.flush()).toEqual(['Count: 7']);
191 expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]);
192 });
@@ -199,10 +204,10 @@ describe('ReactHooksWithNoopRenderer', () => {
204 ReactNoop.flush();
205 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
206
202 - counter.current.updateCount(7);
207 + act(() => counter.current.updateCount(7));
208 expect(ReactNoop.flush()).toEqual(['Count: 7']);
209
205 - counter.current.updateLabel('Total');
210 + act(() => counter.current.updateLabel('Total'));
211 expect(ReactNoop.flush()).toEqual(['Total: 7']);
212 });
213
@@ -217,11 +222,11 @@ describe('ReactHooksWithNoopRenderer', () => {
222 ReactNoop.flush();
223 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
224
220 - updaters[0](1);
225 + act(() => updaters[0](1));
226 ReactNoop.flush();
227 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
228
224 - updaters[0](count => count + 10);
229 + act(() => updaters[0](count => count + 10));
230 ReactNoop.flush();
231 expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
232
@@ -240,7 +245,7 @@ describe('ReactHooksWithNoopRenderer', () => {
245 ReactNoop.flush();
246 ReactNoop.render(null);
247 ReactNoop.flush();
243 - expect(() => _updateCount(1)).toWarnDev(
248 + expect(() => act(() => _updateCount(1))).toWarnDev(
249 "Warning: Can't perform a React state update on an unmounted " +
250 'component. This is a no-op, but it indicates a memory leak in your ' +
251 'application. To fix, cancel all subscriptions and asynchronous ' +
@@ -266,7 +271,7 @@ describe('ReactHooksWithNoopRenderer', () => {
271 expect(ReactNoop.flush()).toEqual([]);
272 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
273
269 - _updateCount(1);
274 + act(() => _updateCount(1));
275 expect(ReactNoop.flush()).toEqual(['Count: 1']);
276 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
277 });
@@ -490,13 +495,15 @@ describe('ReactHooksWithNoopRenderer', () => {
495 ReactNoop.flush();
496 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
497
493 - counter.current.dispatch(INCREMENT);
498 + act(() => counter.current.dispatch(INCREMENT));
499 ReactNoop.flush();
500 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
501 + act(() => {
502 + counter.current.dispatch(DECREMENT);
503 + counter.current.dispatch(DECREMENT);
504 + counter.current.dispatch(DECREMENT);
505 + });
506
497 - counter.current.dispatch(DECREMENT);
498 - counter.current.dispatch(DECREMENT);
499 - counter.current.dispatch(DECREMENT);
507 ReactNoop.flush();
508 expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]);
509 });
@@ -530,13 +537,16 @@ describe('ReactHooksWithNoopRenderer', () => {
537 expect(ReactNoop.flush()).toEqual(['Init', 'Count: 10']);
538 expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]);
539
533 - counter.current.dispatch(INCREMENT);
540 + act(() => counter.current.dispatch(INCREMENT));
541 expect(ReactNoop.flush()).toEqual(['Count: 11']);
542 expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
543
537 - counter.current.dispatch(DECREMENT);
538 - counter.current.dispatch(DECREMENT);
539 - counter.current.dispatch(DECREMENT);
544 + act(() => {
545 + counter.current.dispatch(DECREMENT);
546 + counter.current.dispatch(DECREMENT);
547 + counter.current.dispatch(DECREMENT);
548 + });
549 +
550 expect(ReactNoop.flush()).toEqual(['Count: 8']);
551 expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]);
552 });
@@ -562,9 +572,12 @@ describe('ReactHooksWithNoopRenderer', () => {
572 ReactNoop.flush();
573 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
574
565 - counter.current.dispatch(INCREMENT);
566 - counter.current.dispatch(INCREMENT);
567 - counter.current.dispatch(INCREMENT);
575 + act(() => {
576 + counter.current.dispatch(INCREMENT);
577 + counter.current.dispatch(INCREMENT);
578 + counter.current.dispatch(INCREMENT);
579 + });
580 +
581 ReactNoop.flushSync(() => {
582 counter.current.dispatch(INCREMENT);
583 });
@@ -647,15 +660,20 @@ describe('ReactHooksWithNoopRenderer', () => {
660 });
661 return <Text text="Layout" />;
662 }
663 +
664 ReactNoop.render([<PassiveEffect key="p" />, <LayoutEffect key="l" />]);
651 - expect(ReactNoop.flush()).toEqual([
652 - 'Passive',
653 - 'Layout',
654 - 'Layout effect 0',
655 - 'Passive effect',
656 - 'Layout',
657 - 'Layout effect 1',
658 - ]);
665 +
666 + act(() => {
667 + expect(ReactNoop.flush()).toEqual([
668 + 'Passive',
669 + 'Layout',
670 + 'Layout effect 0',
671 + 'Passive effect',
672 + 'Layout',
673 + 'Layout effect 1',
674 + ]);
675 + });
676 +
677 expect(ReactNoop.getChildren()).toEqual([
678 span('Passive'),
679 span('Layout'),
@@ -779,7 +797,10 @@ describe('ReactHooksWithNoopRenderer', () => {
797 ReactNoop.flushThrough(['Schedule update [0]', 'Count: 0']);
798 expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
799
782 - expect(ReactNoop.flush()).toEqual([]);
800 + ReactNoop.batchedUpdates(() => {
801 + expect(ReactNoop.flush()).toEqual([]);
802 + });
803 +
804 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
805
806 ReactNoop.flushPassiveEffects();
@@ -805,7 +826,7 @@ describe('ReactHooksWithNoopRenderer', () => {
826
827 // Enqueuing this update forces the passive effect to be flushed --
828 // updateCount(1) happens first, so 2 wins.
808 - _updateCount(2);
829 + act(() => _updateCount(2));
830 expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
831 expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
832 });
@@ -851,7 +872,7 @@ describe('ReactHooksWithNoopRenderer', () => {
872
873 // Enqueuing this update forces the passive effect to be flushed --
874 // updateCount(1) happens first, so 2 wins.
854 - _updateCount(2);
875 + act(() => _updateCount(2));
876 expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
877 expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
878
@@ -1368,7 +1389,7 @@ describe('ReactHooksWithNoopRenderer', () => {
1389 span('Count: 0'),
1390 ]);
1391
1371 - button.current.increment();
1392 + act(button.current.increment);
1393 expect(ReactNoop.flush()).toEqual([
1394 // Button should not re-render, because its props haven't changed
1395 // 'Increment',
@@ -1392,7 +1413,7 @@ describe('ReactHooksWithNoopRenderer', () => {
1413 ]);
1414
1415 // Callback should have updated
1395 - button.current.increment();
1416 + act(button.current.increment);
1417 expect(ReactNoop.flush()).toEqual(['Count: 11']);
1418 expect(ReactNoop.getChildren()).toEqual([
1419 span('Increment'),
@@ -1600,8 +1621,11 @@ describe('ReactHooksWithNoopRenderer', () => {
1621 span('A: 0, B: 0, C: [not loaded]'),
1622 ]);
1623
1603 - updateA(2);
1604 - updateB(3);
1624 + act(() => {
1625 + updateA(2);
1626 + updateB(3);
1627 + });
1628 +
1629 expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
1630 expect(ReactNoop.getChildren()).toEqual([
1631 span('A: 2, B: 3, C: [not loaded]'),
@@ -1646,10 +1670,11 @@ describe('ReactHooksWithNoopRenderer', () => {
1670 ReactNoop.render(<App loadC={true} />);
1671 expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: 0']);
1672 expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]);
1649 -
1650 - updateA(2);
1651 - updateB(3);
1652 - updateC(4);
1673 + act(() => {
1674 + updateA(2);
1675 + updateB(3);
1676 + updateC(4);
1677 + });
1678 expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']);
1679 expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]);
1680 ReactNoop.render(<App loadC={false} />);
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+5 -3
@@ -3,6 +3,7 @@ let ReactTestRenderer;
3 let ReactFeatureFlags;
4 let ReactCache;
5 let Suspense;
6 +let act;
7
8 // let JestReact;
9
@@ -19,6 +20,7 @@ describe('ReactSuspense', () => {
20 ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
21 React = require('react');
22 ReactTestRenderer = require('react-test-renderer');
23 + act = ReactTestRenderer.act;
24 // JestReact = require('jest-react');
25 ReactCache = require('react-cache');
26
@@ -797,7 +799,7 @@ describe('ReactSuspense', () => {
799 ]);
800 expect(root).toMatchRenderedOutput('Tab: 0 + sibling');
801
800 - setTab(1);
802 + act(() => setTab(1));
803 expect(ReactTestRenderer).toHaveYielded([
804 'Suspend! [Tab: 1]',
805 ' + sibling',
@@ -811,7 +813,7 @@ describe('ReactSuspense', () => {
813 ]);
814 expect(root).toMatchRenderedOutput('Tab: 1 + sibling');
815
814 - setTab(2);
816 + act(() => setTab(2));
817 expect(ReactTestRenderer).toHaveYielded([
818 'Suspend! [Tab: 2]',
819 ' + sibling',
@@ -864,7 +866,7 @@ describe('ReactSuspense', () => {
866 ]);
867 expect(root).toMatchRenderedOutput('A:0');
868
867 - setStep(1);
869 + act(() => setStep(1));
870 expect(ReactTestRenderer).toHaveYielded(['Suspend! [A:1]', 'Loading...']);
871 expect(root).toMatchRenderedOutput('Loading...');
872
packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
+1 -1
@@ -158,7 +158,7 @@ describe('ReactSuspenseFuzz', () => {
158 if ((elapsedTime += 1000) > 1000000) {
159 throw new Error('Something did not resolve properly.');
160 }
161 - jest.advanceTimersByTime(1000);
161 + ReactTestRenderer.act(() => jest.advanceTimersByTime(1000));
162 root.unstable_flushAll();
163 }
164
packages/react-test-renderer/src/ReactTestRenderer.js
+60 -1
@@ -17,8 +17,8 @@ import {
17 updateContainer,
18 flushSync,
19 injectIntoDevTools,
20 + batchedUpdates,
21 } from 'react-reconciler/inline.test';
21 -import {batchedUpdates} from 'events/ReactGenericBatching';
22 import {findCurrentFiberUsingSlowPath} from 'react-reconciler/reflection';
23 import {
24 Fragment,
@@ -39,6 +39,7 @@ import {
39 } from 'shared/ReactWorkTags';
40 import invariant from 'shared/invariant';
41 import ReactVersion from 'shared/ReactVersion';
42 +import warningWithoutStack from 'shared/warningWithoutStack';
43
44 import {getPublicInstance} from './ReactTestHostConfig';
45 import {
@@ -70,6 +71,11 @@ type FindOptions = $Shape<{
71
72 export type Predicate = (node: ReactTestInstance) => ?boolean;
73
74 +// for .act's return value
75 +type Thenable = {
76 + then(resolve: () => mixed, reject?: () => mixed): mixed,
77 +};
78 +
79 const defaultTestOptions = {
80 createNodeMock: function() {
81 return null;
@@ -557,8 +563,61 @@ const ReactTestRendererFiber = {
563 /* eslint-enable camelcase */
564
565 unstable_setNowImplementation: setNowImplementation,
566 +
567 + act(callback: () => void): Thenable {
568 + // note: keep these warning messages in sync with
569 + // createNoop.js and ReactTestUtils.js
570 + let result = batchedUpdates(callback);
571 + if (__DEV__) {
572 + if (result !== undefined) {
573 + let addendum;
574 + if (typeof result.then === 'function') {
575 + addendum =
576 + "\n\nIt looks like you wrote TestRenderer.act(async () => ...) or returned a Promise from it's callback. " +
577 + 'Putting asynchronous logic inside TestRenderer.act(...) is not supported.\n';
578 + } else {
579 + addendum = ' You returned: ' + result;
580 + }
581 + warningWithoutStack(
582 + false,
583 + 'The callback passed to TestRenderer.act(...) function must not return anything.%s',
584 + addendum,
585 + );
586 + }
587 + }
588 + flushPassiveEffects();
589 + // we want the user to not expect a return,
590 + // but we want to warn if they use it like they can await on it.
591 + return {
592 + then() {
593 + if (__DEV__) {
594 + warningWithoutStack(
595 + false,
596 + 'Do not await the result of calling TestRenderer.act(...), it is not a Promise.',
597 + );
598 + }
599 + },
600 + };
601 + },
602 };
603
604 +// root used to flush effects during .act() calls
605 +const actRoot = createContainer(
606 + {
607 + children: [],
608 + createNodeMock: defaultTestOptions.createNodeMock,
609 + tag: 'CONTAINER',
610 + },
611 + true,
612 + false,
613 +);
614 +
615 +function flushPassiveEffects() {
616 + // Trick to flush passive effects without exposing an internal API:
617 + // Create a throwaway root and schedule a dummy update on it.
618 + updateContainer(null, actRoot, null, null);
619 +}
620 +
621 const fiberToWrapper = new WeakMap();
622 function wrapFiber(fiber: Fiber): ReactTestInstance {
623 let wrapper = fiberToWrapper.get(fiber);
packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
+23
@@ -1021,4 +1021,27 @@ describe('ReactTestRenderer', () => {
1021 ReactNoop.flush();
1022 ReactTestRenderer.create(<App />);
1023 });
1024 +
1025 + describe('act', () => {
1026 + it('works', () => {
1027 + function App(props) {
1028 + React.useEffect(() => {
1029 + props.callback();
1030 + });
1031 + return null;
1032 + }
1033 + let called = false;
1034 + ReactTestRenderer.act(() => {
1035 + ReactTestRenderer.create(
1036 + <App
1037 + callback={() => {
1038 + called = true;
1039 + }}
1040 + />,
1041 + );
1042 + });
1043 +
1044 + expect(called).toBe(true);
1045 + });
1046 + });
1047 });