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); + }); + }); });