await act(async () => ...) (#14853)

This took a while, but I'm happy I went through it. Some key moments - recursively flushing effects, flushing microtasks on each async turn, and my team's uncompromising philosophy on code reuse. Really happy with this. I still want to expand test coverage, and I have some more small related todos, but this is good to land. On to the next one. 

Soundtrack to landing this - https://open.spotify.com/track/0MF8I8OUo8kytiOo8aSHYq?si=gSWqUheKQbiQDXzptCXHTg

* hacked up act(async () => {...})

* move stuff around

* merge changes

* abstract .act warnings and stuff. all renderers. pass all tests.

* move testutils.act back into testutils

* move into scheduler, rename some bits

* smaller bundle

* a comment for why we don't do typeof === 'function'

* fix test

* pass tests - fire, prod

* lose actContainerElement

* tighter

* write a test for TestRenderer

it's an odd one, because not only does sync act not flush effects correctly, but the async one does (wut). verified it's fine with the dom version.

* lint

* rewrote to move flushing logic closer to the renderer

the scheduler's `flushPassiveEffects` didn't work as expected for the test renderer, so I decided to go back to the hack (rendering a dumb container) This also makes reactdom not as heavy (by a few bytes, but still).

* move it around so the delta isn't too bad

* cleanups

fix promise chaining
propagate errors correctly
test for thenable the 'right' way
more tests!
tidier!
ponies!

* Stray comment

* recursively flush effects

* fixed tests

* lint, move noop.act into react-reconciler

* microtasks when checking if called, s/called/calledLog, cleanup

* pass fb lint

we could have globally changed our eslint config to assume Promise is available, but that means we expect a promise polyfill on the page, and we don't yet. this code is triggered only in jest anyway, and we're fairly certain Promise will be available there. hence, the once-off disable for the check

* shorter timers, fix a test, test for Promise

* use global.Promise for existence check

* flush microtasks

* a version that works in browsers (that support postMessage)

I also added a sanity fixture inside fixtures/dom/ mostly for me.

* hoist flushEffectsAndMicroTasks

* pull out tick logic from ReactFiberScheduler

* fix await act (...sync) hanging

- fix a hang when awaiting sync logic
- a better async/await test for test renderer

* feedback changes

- use node's setImmediate if available
- a warning if MessageChannel isn't available
- rename some functions

* pass lint/flow checks (without requiring a Promise polyfill/exclusion)

* prettier

the prettiest, even.

* use globalPromise for the missed await warning

* __DEV__ check for didWarnAboutMessageChannel

* thenables and callbacks instead of promises, pass flow/lint

* tinier. better.

- pulled most bits out of FiberScheduler
- actedUpdates uses callbacks now

* pass build validation

* augh prettier

* golfing 7 more chars

* Test that effects are not flushed without also flushing microtasks

* export doesHavePendingPassiveEffects, nits

* createAct()

* dead code

* missed in merge?

* lose the preflushing bits

* ugh prettier

* removed `actedUpdates()`, created shared/actingUpdatesScopeDepth

* rearrange imports so builds work, remove the hack versions of flushPassiveEffects

* represent actingUpdatesScopeDepth as a tuple [number]

* use a shared flag on React.__SECRET...

* remove createAct, setup act for all relevant renderers

* review feedback

shared/enqueueTask

import ReactSharedInternals from 'shared/ReactSharedInternals';

simpler act() internals

ReactSharedInternals.ReactShouldWarnActingUpdates

* move act() implementation into createReactNoop

* warnIfNotCurrentlyActingUpdatesInDev condition check order
This commit is contained in:
Sunil Pai 2019-04-02 22:33:31 +01:00 committed by GitHub
parent 4c75881ee3
commit aed0e1c30c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1638 additions and 599 deletions

View File

@ -14,6 +14,8 @@ public/react-dom.development.js
public/react-dom.production.min.js
public/react-dom-server.browser.development.js
public/react-dom-server.browser.production.min.js
public/react-dom-test-utils.development.js
public/react-dom-test-utils.production.min.js
# misc
.DS_Store

View File

@ -18,7 +18,7 @@
},
"scripts": {
"start": "react-scripts start",
"prestart": "cp ../../build/node_modules/react/umd/react.development.js ../../build/node_modules/react-dom/umd/react-dom.development.js ../../build/node_modules/react/umd/react.production.min.js ../../build/node_modules/react-dom/umd/react-dom.production.min.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.production.min.js public/",
"prestart": "cp ../../build/node_modules/react/umd/react.development.js ../../build/node_modules/react-dom/umd/react-dom.development.js ../../build/node_modules/react/umd/react.production.min.js ../../build/node_modules/react-dom/umd/react-dom.production.min.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.production.min.js ../../build/node_modules/react-dom/umd/react-dom-test-utils.development.js ../../build/node_modules/react-dom/umd/react-dom-test-utils.production.min.js public/",
"build": "react-scripts build && cp build/index.html build/200.html",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>sanity test for ReactTestUtils.act</title>
</head>
<body>
this page tests whether act runs properly in a browser.
<br/>
your console should say "5"
<script src='react.development.js'></script>
<script src='react-dom.development.js'></script>
<script src='react-dom-test-utils.development.js'></script>
<script>
async function run(){
// from ReactTestUtilsAct-test.js
function App() {
let [state, setState] = React.useState(0);
async function ticker() {
await null;
setState(x => x + 1);
}
React.useEffect(
() => {
ticker();
},
[Math.min(state, 4)],
);
return state;
}
const el = document.createElement('div');
await ReactTestUtils.act(async () => {
ReactDOM.render(React.createElement(App), el);
});
// all 5 ticks present and accounted for
console.log(el.innerHTML);
}
run();
</script>
</body>
</html>

View File

@ -14,7 +14,6 @@ let React;
let ReactDOM;
let ReactDOMServer;
let ReactTestUtils;
let act;
function getTestDocument(markup) {
const doc = document.implementation.createHTMLDocument('');
@ -34,7 +33,6 @@ 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', () => {
@ -517,173 +515,4 @@ 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(...).',
]);
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(() => null)).toWarnDev(
[
'The callback passed to ReactTestUtils.act(...) function must not return anything.',
],
{withoutStack: true},
);
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},
);
});
});

View File

@ -0,0 +1,403 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
let React;
let ReactDOM;
let ReactTestUtils;
let act;
jest.useRealTimers();
function sleep(period) {
return new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, period);
});
}
describe('ReactTestUtils.act()', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactTestUtils = require('react-dom/test-utils');
act = ReactTestUtils.act;
});
describe('sync', () => {
it('can use act to flush effects', () => {
function App(props) {
React.useEffect(props.callback);
return null;
}
let calledLog = [];
act(() => {
ReactDOM.render(
<App
callback={() => {
calledLog.push(calledLog.length);
}}
/>,
document.createElement('div'),
);
});
expect(calledLog).toEqual([0]);
});
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)}>
{ctr}
</button>
);
}
const container = document.createElement('div');
// attach to body so events works
document.body.appendChild(container);
let calledCounter = 0;
act(() => {
ReactDOM.render(
<App
callback={val => {
calledCounter = val;
}}
/>,
container,
);
});
const button = document.getElementById('button');
function click() {
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
}
act(() => {
click();
click();
click();
});
expect(calledCounter).toBe(3);
act(click);
expect(calledCounter).toBe(4);
act(click);
expect(calledCounter).toBe(5);
expect(button.innerHTML).toBe('5');
document.body.removeChild(container);
});
it('should flush effects recursively', () => {
function App() {
let [ctr, setCtr] = React.useState(0);
React.useEffect(() => {
if (ctr < 5) {
setCtr(x => x + 1);
}
});
return ctr;
}
const container = document.createElement('div');
act(() => {
ReactDOM.render(<App />, container);
});
expect(container.innerHTML).toBe('5');
});
it('detects setState being called outside of act(...)', () => {
let setValue = null;
function App() {
let [value, _setValue] = React.useState(0);
setValue = _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(() => setValue(1)).toWarnDev([
'An update to App inside a test was not wrapped in act(...).',
]);
document.body.removeChild(container);
});
describe('fake timers', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
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(() => {
ReactDOM.render(<App />, container);
});
act(() => {
jest.runAllTimers();
});
expect(container.innerHTML).toBe('1');
});
it('can use the async version to catch microtasks', async () => {
function App() {
let [toggle, setToggle] = React.useState(0);
React.useEffect(() => {
// just like the previous test, except we
// use a promise and schedule the update
// after it resolves
sleep(200).then(() => setToggle(1));
}, []);
return toggle;
}
const container = document.createElement('div');
act(() => {
ReactDOM.render(<App />, container);
});
await act(async () => {
jest.runAllTimers();
});
expect(container.innerHTML).toBe('1');
});
it('can handle cascading promises with fake timers', async () => {
// this component triggers an effect, that waits a tick,
// then sets state. repeats this 5 times.
function App() {
let [state, setState] = React.useState(0);
async function ticker() {
await null;
setState(x => x + 1);
}
React.useEffect(
() => {
ticker();
},
[Math.min(state, 4)],
);
return state;
}
const el = document.createElement('div');
await act(async () => {
ReactDOM.render(<App />, el);
});
// all 5 ticks present and accounted for
expect(el.innerHTML).toBe('5');
});
});
it('warns if you return a value inside act', () => {
expect(() => act(() => null)).toWarnDev(
[
'The callback passed to act(...) function must return undefined, or a Promise.',
],
{withoutStack: true},
);
expect(() => act(() => 123)).toWarnDev(
[
'The callback passed to act(...) function must return undefined, or a Promise.',
],
{withoutStack: true},
);
});
it('warns if you try to await an .act call', () => {
expect(() => act(() => {}).then(() => {})).toWarnDev(
[
'Do not await the result of calling act(...) with sync logic, it is not a Promise.',
],
{withoutStack: true},
);
});
});
describe('asynchronous tests', () => {
it('can handle timers', async () => {
function App() {
let [ctr, setCtr] = React.useState(0);
function doSomething() {
setTimeout(() => {
setCtr(1);
}, 50);
}
React.useEffect(() => {
doSomething();
}, []);
return ctr;
}
const el = document.createElement('div');
await act(async () => {
act(() => {
ReactDOM.render(<App />, el);
});
await sleep(100);
expect(el.innerHTML).toBe('1');
});
});
it('can handle async/await', async () => {
function App() {
let [ctr, setCtr] = React.useState(0);
async function someAsyncFunction() {
// queue a bunch of promises to be sure they all flush
await null;
await null;
await null;
setCtr(1);
}
React.useEffect(() => {
someAsyncFunction();
}, []);
return ctr;
}
const el = document.createElement('div');
await act(async () => {
act(() => {
ReactDOM.render(<App />, el);
});
// pending promises will close before this ends
});
expect(el.innerHTML).toEqual('1');
});
it('warns if you do not await an act call', async () => {
spyOnDevAndProd(console, 'error');
act(async () => {});
// it's annoying that we have to wait a tick before this warning comes in
await sleep(0);
if (__DEV__) {
expect(console.error.calls.count()).toEqual(1);
expect(console.error.calls.argsFor(0)[0]).toMatch(
'You called act(async () => ...) without await.',
);
}
});
it('warns if you try to interleave multiple act calls', async () => {
spyOnDevAndProd(console, 'error');
// let's try to cheat and spin off a 'thread' with an act call
(async () => {
await act(async () => {
await sleep(50);
});
})();
await act(async () => {
await sleep(100);
});
await sleep(150);
if (__DEV__) {
expect(console.error).toHaveBeenCalledTimes(1);
}
});
it('commits and effects are guaranteed to be flushed', async () => {
function App(props) {
let [state, setState] = React.useState(0);
async function something() {
await null;
setState(1);
}
React.useEffect(() => {
something();
}, []);
React.useEffect(() => {
props.callback();
});
return state;
}
let ctr = 0;
const div = document.createElement('div');
await act(async () => {
act(() => {
ReactDOM.render(<App callback={() => ctr++} />, div);
});
expect(div.innerHTML).toBe('0');
expect(ctr).toBe(1);
});
// this may seem odd, but it matches user behaviour -
// a flash of "0" followed by "1"
expect(div.innerHTML).toBe('1');
expect(ctr).toBe(2);
});
it('propagates errors', async () => {
let err;
try {
await act(async () => {
throw new Error('some error');
});
} catch (_err) {
err = _err;
} finally {
expect(err instanceof Error).toBe(true);
expect(err.message).toBe('some error');
}
});
it('can handle cascading promises', async () => {
// this component triggers an effect, that waits a tick,
// then sets state. repeats this 5 times.
function App() {
let [state, setState] = React.useState(0);
async function ticker() {
await null;
setState(x => x + 1);
}
React.useEffect(
() => {
ticker();
},
[Math.min(state, 4)],
);
return state;
}
const el = document.createElement('div');
await act(async () => {
ReactDOM.render(<App />, el);
});
// all 5 ticks present and accounted for
expect(el.innerHTML).toBe('5');
});
});
});

View File

@ -35,6 +35,7 @@ import {
getPublicRootInstance,
findHostInstance,
findHostInstanceWithWarning,
flushPassiveEffects,
} from 'react-reconciler/inline.dom';
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
import {canUseDOM} from 'shared/ExecutionEnvironment';
@ -807,7 +808,7 @@ const ReactDOM: Object = {
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: {
// Keep in sync with ReactDOMUnstableNativeDependencies.js
// and ReactTestUtils.js. This is an array for better minification.
// ReactTestUtils.js, and ReactTestUtilsAct.js. This is an array for better minification.
Events: [
getInstanceFromNode,
getNodeFromInstance,
@ -820,6 +821,7 @@ const ReactDOM: Object = {
restoreStateIfNeeded,
dispatchEvent,
runEventsInBatch,
flushPassiveEffects,
],
},
};

View File

@ -40,6 +40,7 @@ import {
getPublicRootInstance,
findHostInstance,
findHostInstanceWithWarning,
flushPassiveEffects,
} from 'react-reconciler/inline.fire';
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
import {canUseDOM} from 'shared/ExecutionEnvironment';
@ -826,6 +827,7 @@ const ReactDOM: Object = {
restoreStateIfNeeded,
dispatchEvent,
runEventsInBatch,
flushPassiveEffects,
],
},
};

View File

@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {Thenable} from 'react-reconciler/src/ReactFiberScheduler';
import React from 'react';
import ReactDOM from 'react-dom';
@ -22,15 +23,11 @@ import warningWithoutStack from 'shared/warningWithoutStack';
import {ELEMENT_NODE} from '../shared/HTMLNodeType';
import * as DOMTopLevelEventTypes from '../events/DOMTopLevelEventTypes';
import {PLUGIN_EVENT_SYSTEM} from 'events/EventSystemFlags';
// for .act's return value
type Thenable = {
then(resolve: () => mixed, reject?: () => mixed): mixed,
};
import act from './ReactTestUtilsAct';
const {findDOMNode} = ReactDOM;
// Keep in sync with ReactDOMUnstableNativeDependencies.js
// and ReactDOM.js:
// ReactDOM.js, and ReactTestUtilsAct.js:
const [
getInstanceFromNode,
/* eslint-disable no-unused-vars */
@ -45,6 +42,8 @@ const [
restoreStateIfNeeded,
dispatchEvent,
runEventsInBatch,
// eslint-disable-next-line no-unused-vars
flushPassiveEffects,
] = ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Events;
function Event(suffix) {}
@ -152,9 +151,12 @@ function validateClassInstance(inst, methodName) {
);
}
// a stub element, lazily initialized, used by act() when flushing effects
// a plain dom element, lazily initialized, used by act() when flushing effects
let actContainerElement = null;
// a warning for when you try to use TestUtils.act in a non-browser environment
let didWarnAboutActInNodejs = false;
/**
* Utilities for making it easy to test React components.
*
@ -391,57 +393,24 @@ const ReactTestUtils = {
Simulate: null,
SimulateNative: {},
act(callback: () => void): Thenable {
act(callback: () => Thenable) {
if (actContainerElement === null) {
// warn if we can't actually create the stub element
if (__DEV__) {
// warn if we're trying to use this in something like node (without jsdom)
if (didWarnAboutActInNodejs === false) {
didWarnAboutActInNodejs = true;
warningWithoutStack(
typeof document !== 'undefined' &&
document !== null &&
typeof document.createElement === 'function',
'It looks like you called TestUtils.act(...) in a non-browser environment. ' +
typeof document !== 'undefined' && document !== null,
'It looks like you called ReactTestUtils.act(...) in a non-browser environment. ' +
"If you're using TestRenderer for your tests, you should call " +
'TestRenderer.act(...) instead of TestUtils.act(...).',
'ReactTestRenderer.act(...) instead of ReactTestUtils.act(...).',
);
}
// then make it
}
// now make the stub element
actContainerElement = document.createElement('div');
}
const result = ReactDOM.unstable_batchedUpdates(callback);
// note: keep these warning messages in sync with
// createReactNoop.js and ReactTestRenderer.js
if (__DEV__) {
if (result !== undefined) {
let addendum;
if (result !== null && 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.',
);
}
},
};
return act(callback);
},
};

View File

@ -0,0 +1,172 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {Thenable} from 'react-reconciler/src/ReactFiberScheduler';
import warningWithoutStack from 'shared/warningWithoutStack';
import ReactDOM from 'react-dom';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import enqueueTask from 'shared/enqueueTask';
// Keep in sync with ReactDOMUnstableNativeDependencies.js
// ReactDOM.js, and ReactTestUtils.js:
const [
/* eslint-disable no-unused-vars */
getInstanceFromNode,
getNodeFromInstance,
getFiberCurrentPropsFromNode,
injectEventPluginsByName,
eventNameDispatchConfigs,
accumulateTwoPhaseDispatches,
accumulateDirectDispatches,
enqueueStateRestore,
restoreStateIfNeeded,
dispatchEvent,
runEventsInBatch,
/* eslint-enable no-unused-vars */
flushPassiveEffects,
] = ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Events;
const batchedUpdates = ReactDOM.unstable_batchedUpdates;
const {ReactShouldWarnActingUpdates} = ReactSharedInternals;
// this implementation should be exactly the same in
// ReactTestUtilsAct.js, ReactTestRendererAct.js, createReactNoop.js
// we track the 'depth' of the act() calls with this counter,
// so we can tell if any async act() calls try to run in parallel.
let actingUpdatesScopeDepth = 0;
function flushEffectsAndMicroTasks(onDone: (err: ?Error) => void) {
try {
flushPassiveEffects();
enqueueTask(() => {
if (flushPassiveEffects()) {
flushEffectsAndMicroTasks(onDone);
} else {
onDone();
}
});
} catch (err) {
onDone(err);
}
}
function act(callback: () => Thenable) {
let previousActingUpdatesScopeDepth;
if (__DEV__) {
previousActingUpdatesScopeDepth = actingUpdatesScopeDepth;
actingUpdatesScopeDepth++;
ReactShouldWarnActingUpdates.current = true;
}
function onDone() {
if (__DEV__) {
actingUpdatesScopeDepth--;
if (actingUpdatesScopeDepth === 0) {
ReactShouldWarnActingUpdates.current = false;
}
if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) {
// if it's _less than_ previousActingUpdatesScopeDepth, then we can assume the 'other' one has warned
warningWithoutStack(
null,
'You seem to have overlapping act() calls, this is not supported. ' +
'Be sure to await previous act() calls before making a new one. ',
);
}
}
}
const result = batchedUpdates(callback);
if (
result !== null &&
typeof result === 'object' &&
typeof result.then === 'function'
) {
// setup a boolean that gets set to true only
// once this act() call is await-ed
let called = false;
if (__DEV__) {
if (typeof Promise !== 'undefined') {
//eslint-disable-next-line no-undef
Promise.resolve()
.then(() => {})
.then(() => {
if (called === false) {
warningWithoutStack(
null,
'You called act(async () => ...) without await. ' +
'This could lead to unexpected testing behaviour, interleaving multiple act ' +
'calls and mixing their scopes. You should - await act(async () => ...);',
);
}
});
}
}
// in the async case, the returned thenable runs the callback, flushes
// effects and microtasks in a loop until flushPassiveEffects() === false,
// and cleans up
return {
then(resolve: () => void, reject: (?Error) => void) {
called = true;
result.then(
() => {
flushEffectsAndMicroTasks((err: ?Error) => {
onDone();
if (err) {
reject(err);
} else {
resolve();
}
});
},
err => {
onDone();
reject(err);
},
);
},
};
} else {
if (__DEV__) {
warningWithoutStack(
result === undefined,
'The callback passed to act(...) function ' +
'must return undefined, or a Promise. You returned %s',
result,
);
}
// flush effects until none remain, and cleanup
try {
while (flushPassiveEffects()) {}
onDone();
} catch (err) {
onDone();
throw err;
}
// in the sync case, the returned thenable only warns *if* await-ed
return {
then(resolve: () => void) {
if (__DEV__) {
warningWithoutStack(
false,
'Do not await the result of calling act(...) with sync logic, it is not a Promise.',
);
}
resolve();
},
};
}
}
export default act;

View File

@ -11,7 +11,7 @@ import ResponderEventPlugin from 'events/ResponderEventPlugin';
import ResponderTouchHistoryStore from 'events/ResponderTouchHistoryStore';
// Inject react-dom's ComponentTree into this module.
// Keep in sync with ReactDOM.js and ReactTestUtils.js:
// Keep in sync with ReactDOM.js, ReactTestUtils.js, and ReactTestUtilsAct.js:
const [
getInstanceFromNode,
getNodeFromInstance,

View File

@ -14,6 +14,7 @@
* environment.
*/
import type {Thenable} from 'react-reconciler/src/ReactFiberScheduler';
import type {Fiber} from 'react-reconciler/src/ReactFiber';
import type {UpdateQueue} from 'react-reconciler/src/ReactUpdateQueue';
import type {ReactNodeList} from 'shared/ReactTypes';
@ -26,16 +27,12 @@ import {
REACT_ELEMENT_TYPE,
REACT_EVENT_TARGET_TOUCH_HIT,
} from 'shared/ReactSymbols';
import warningWithoutStack from 'shared/warningWithoutStack';
import warning from 'shared/warning';
import enqueueTask from 'shared/enqueueTask';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import warningWithoutStack from 'shared/warningWithoutStack';
import {enableEventAPI} from 'shared/ReactFeatureFlags';
// for .act's return value
type Thenable = {
then(resolve: () => mixed, reject?: () => mixed): mixed,
};
type Container = {
rootID: string,
children: Array<Instance | TextInstance>,
@ -59,6 +56,8 @@ type TextInstance = {|
|};
type HostContext = Object;
const {ReactShouldWarnActingUpdates} = ReactSharedInternals;
const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
const EVENT_COMPONENT_CONTEXT = {};
@ -598,6 +597,140 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
const roots = new Map();
const DEFAULT_ROOT_ID = '<default>';
const {flushPassiveEffects, batchedUpdates} = NoopRenderer;
// this act() implementation should be exactly the same in
// ReactTestUtilsAct.js, ReactTestRendererAct.js, createReactNoop.js
let actingUpdatesScopeDepth = 0;
function flushEffectsAndMicroTasks(onDone: (err: ?Error) => void) {
try {
flushPassiveEffects();
enqueueTask(() => {
if (flushPassiveEffects()) {
flushEffectsAndMicroTasks(onDone);
} else {
onDone();
}
});
} catch (err) {
onDone(err);
}
}
function act(callback: () => Thenable) {
let previousActingUpdatesScopeDepth;
if (__DEV__) {
previousActingUpdatesScopeDepth = actingUpdatesScopeDepth;
actingUpdatesScopeDepth++;
ReactShouldWarnActingUpdates.current = true;
}
function onDone() {
if (__DEV__) {
actingUpdatesScopeDepth--;
if (actingUpdatesScopeDepth === 0) {
ReactShouldWarnActingUpdates.current = false;
}
if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) {
// if it's _less than_ previousActingUpdatesScopeDepth, then we can assume the 'other' one has warned
warningWithoutStack(
null,
'You seem to have overlapping act() calls, this is not supported. ' +
'Be sure to await previous act() calls before making a new one. ',
);
}
}
}
const result = batchedUpdates(callback);
if (
result !== null &&
typeof result === 'object' &&
typeof result.then === 'function'
) {
// setup a boolean that gets set to true only
// once this act() call is await-ed
let called = false;
if (__DEV__) {
if (typeof Promise !== 'undefined') {
//eslint-disable-next-line no-undef
Promise.resolve()
.then(() => {})
.then(() => {
if (called === false) {
warningWithoutStack(
null,
'You called act(async () => ...) without await. ' +
'This could lead to unexpected testing behaviour, interleaving multiple act ' +
'calls and mixing their scopes. You should - await act(async () => ...);',
);
}
});
}
}
// in the async case, the returned thenable runs the callback, flushes
// effects and microtasks in a loop until flushPassiveEffects() === false,
// and cleans up
return {
then(resolve: () => void, reject: (?Error) => void) {
called = true;
result.then(
() => {
flushEffectsAndMicroTasks((err: ?Error) => {
onDone();
if (err) {
reject(err);
} else {
resolve();
}
});
},
err => {
onDone();
reject(err);
},
);
},
};
} else {
if (__DEV__) {
warningWithoutStack(
result === undefined,
'The callback passed to act(...) function ' +
'must return undefined, or a Promise. You returned %s',
result,
);
}
// flush effects until none remain, and cleanup
try {
while (flushPassiveEffects()) {}
onDone();
} catch (err) {
onDone();
throw err;
}
// in the sync case, the returned thenable only warns *if* await-ed
return {
then(resolve: () => void) {
if (__DEV__) {
warningWithoutStack(
false,
'Do not await the result of calling act(...) with sync logic, it is not a Promise.',
);
}
resolve();
},
};
}
}
// end act() implementation
function childToJSX(child, text) {
if (text !== null) {
return text;
@ -843,56 +976,13 @@ 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 (result !== null && 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) {
NoopRenderer.flushSync(fn);
},
flushPassiveEffects() {
// Trick to flush passive effects without exposing an internal API:
// Create a throwaway root and schedule a dummy update on it.
const rootID = 'bloopandthenmoreletterstoavoidaconflict';
const container = {rootID: rootID, pendingChildren: [], children: []};
rootContainers.set(rootID, container);
const root = NoopRenderer.createContainer(container, true, false);
NoopRenderer.updateContainer(null, root, null, null);
},
flushPassiveEffects: NoopRenderer.flushPassiveEffects,
act,
// Logs the current state of the tree.
dumpTree(rootID: string = DEFAULT_ROOT_ID) {

View File

@ -30,10 +30,10 @@ import {
} from './ReactHookEffectTags';
import {
scheduleWork,
warnIfNotCurrentlyBatchingInDev,
computeExpirationForFiber,
flushPassiveEffects,
requestCurrentTime,
warnIfNotCurrentlyActingUpdatesInDev,
} from './ReactFiberScheduler';
import invariant from 'shared/invariant';
@ -1046,19 +1046,6 @@ 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 isn't 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>,
@ -1178,8 +1165,11 @@ function dispatchAction<S, A>(
}
}
if (__DEV__) {
if (shouldWarnForUnbatchedSetState === true) {
warnIfNotCurrentlyBatchingInDev(fiber);
// jest isn't 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) {
warnIfNotCurrentlyActingUpdatesInDev(fiber);
}
}
scheduleWork(fiber, expirationTime);

View File

@ -310,6 +310,7 @@ export {
flushInteractiveUpdates,
flushControlled,
flushSync,
flushPassiveEffects,
};
export function getPublicRootInstance(

View File

@ -34,7 +34,7 @@ import {
flushInteractiveUpdates as flushInteractiveUpdates_old,
computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_old,
flushPassiveEffects as flushPassiveEffects_old,
warnIfNotCurrentlyBatchingInDev as warnIfNotCurrentlyBatchingInDev_old,
warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_old,
} from './ReactFiberScheduler.old';
import {
@ -62,7 +62,7 @@ import {
flushInteractiveUpdates as flushInteractiveUpdates_new,
computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_new,
flushPassiveEffects as flushPassiveEffects_new,
warnIfNotCurrentlyBatchingInDev as warnIfNotCurrentlyBatchingInDev_new,
warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_new,
} from './ReactFiberScheduler.new';
export let requestCurrentTime = requestCurrentTime_old;
@ -89,7 +89,7 @@ export let interactiveUpdates = interactiveUpdates_old;
export let flushInteractiveUpdates = flushInteractiveUpdates_old;
export let computeUniqueAsyncExpiration = computeUniqueAsyncExpiration_old;
export let flushPassiveEffects = flushPassiveEffects_old;
export let warnIfNotCurrentlyBatchingInDev = warnIfNotCurrentlyBatchingInDev_old;
export let warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDev_old;
if (enableNewScheduler) {
requestCurrentTime = requestCurrentTime_new;
@ -116,9 +116,9 @@ if (enableNewScheduler) {
flushInteractiveUpdates = flushInteractiveUpdates_new;
computeUniqueAsyncExpiration = computeUniqueAsyncExpiration_new;
flushPassiveEffects = flushPassiveEffects_new;
warnIfNotCurrentlyBatchingInDev = warnIfNotCurrentlyBatchingInDev_new;
warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDev_new;
}
export type Thenable = {
then(resolve: () => mixed, reject?: () => mixed): mixed,
then(resolve: () => mixed, reject?: () => mixed): void | Thenable,
};

View File

@ -33,4 +33,4 @@ export const interactiveUpdates = notYetImplemented;
export const flushInteractiveUpdates = notYetImplemented;
export const computeUniqueAsyncExpiration = notYetImplemented;
export const flushPassiveEffects = notYetImplemented;
export const warnIfNotCurrentlyBatchingInDev = notYetImplemented;
export const warnIfNotCurrentlyActingUpdatesInDev = notYetImplemented;

View File

@ -176,10 +176,14 @@ const {
} = Scheduler;
export type Thenable = {
then(resolve: () => mixed, reject?: () => mixed): mixed,
then(resolve: () => mixed, reject?: () => mixed): void | Thenable,
};
const {ReactCurrentDispatcher, ReactCurrentOwner} = ReactSharedInternals;
const {
ReactCurrentDispatcher,
ReactCurrentOwner,
ReactShouldWarnActingUpdates,
} = ReactSharedInternals;
let didWarnAboutStateTransition;
let didWarnSetStateChildContext;
@ -610,6 +614,7 @@ function markLegacyErrorBoundaryAsFailed(instance: mixed) {
}
function flushPassiveEffects() {
const didFlushEffects = passiveEffectCallback !== null;
if (passiveEffectCallbackHandle !== null) {
cancelCallback(passiveEffectCallbackHandle);
}
@ -618,6 +623,7 @@ function flushPassiveEffects() {
// to ensure tracing works correctly.
passiveEffectCallback();
}
return didFlushEffects;
}
function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
@ -1836,9 +1842,20 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
return root;
}
export function warnIfNotCurrentlyBatchingInDev(fiber: Fiber): void {
// in a test-like environment, we want to warn if dispatchAction() is
// called outside of a TestUtils.act(...)/batchedUpdates/render call.
// so we have a a step counter for when we descend/ascend from
// act() calls, and test on it for when to warn
// It's a tuple with a single value. Look for shared/createAct to
// see how we change the value inside act() calls
export function warnIfNotCurrentlyActingUpdatesInDev(fiber: Fiber): void {
if (__DEV__) {
if (isRendering === false && isBatchingUpdates === false) {
if (
isBatchingUpdates === false &&
isRendering === false &&
ReactShouldWarnActingUpdates.current === false
) {
warningWithoutStack(
false,
'An update to %s inside a test was not wrapped in act(...).\n\n' +

View File

@ -1046,11 +1046,10 @@ describe('ReactHooks', () => {
class Cls extends React.Component {
render() {
act(() =>
_setState(() => {
ReactCurrentDispatcher.current.readContext(ThemeContext);
}),
_setState(() =>
ReactCurrentDispatcher.current.readContext(ThemeContext),
);
return null;
}
}
@ -1062,13 +1061,7 @@ describe('ReactHooks', () => {
<Cls />
</React.Fragment>,
),
).toWarnDev(
[
'Context can only be read while React is rendering',
'Render methods should be a pure function of props and state',
],
{withoutStack: 1},
);
).toWarnDev(['Context can only be read while React is rendering']);
});
it('warns when calling hooks inside useReducer', () => {

View File

@ -0,0 +1,63 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment node
*/
// sanity tests for ReactNoop.act()
jest.useRealTimers();
const React = require('react');
const ReactNoop = require('react-noop-renderer');
const Scheduler = require('scheduler');
describe('ReactNoop.act()', () => {
it('can use act to flush effects', async () => {
function App(props) {
React.useEffect(props.callback);
return null;
}
let calledLog = [];
ReactNoop.act(() => {
ReactNoop.render(
<App
callback={() => {
calledLog.push(calledLog.length);
}}
/>,
);
});
expect(Scheduler).toFlushWithoutYielding();
expect(calledLog).toEqual([0]);
});
it('should work with async/await', async () => {
function App() {
let [ctr, setCtr] = React.useState(0);
async function someAsyncFunction() {
Scheduler.yieldValue('stage 1');
await null;
Scheduler.yieldValue('stage 2');
setCtr(1);
}
React.useEffect(() => {
someAsyncFunction();
}, []);
return ctr;
}
await ReactNoop.act(async () => {
ReactNoop.act(() => {
ReactNoop.render(<App />);
});
await null;
expect(Scheduler).toFlushAndYield(['stage 1']);
});
expect(Scheduler).toHaveYielded(['stage 2']);
expect(Scheduler).toFlushWithoutYielding();
expect(ReactNoop.getChildren()).toEqual([{text: '1', hidden: false}]);
});
});

View File

@ -40,7 +40,7 @@ import {
} from 'shared/ReactWorkTags';
import invariant from 'shared/invariant';
import ReactVersion from 'shared/ReactVersion';
import warningWithoutStack from 'shared/warningWithoutStack';
import act from './ReactTestRendererAct';
import {getPublicInstance} from './ReactTestHostConfig';
@ -65,11 +65,6 @@ 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;
@ -549,64 +544,12 @@ const ReactTestRendererFiber = {
return entry;
},
/* eslint-disable camelcase */
/* eslint-disable-next-line camelcase */
unstable_batchedUpdates: batchedUpdates,
/* eslint-enable camelcase */
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 (result !== null && 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.',
);
}
},
};
},
act,
};
// 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);

View File

@ -0,0 +1,153 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {Thenable} from 'react-reconciler/src/ReactFiberScheduler';
import {
batchedUpdates,
flushPassiveEffects,
} from 'react-reconciler/inline.test';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import warningWithoutStack from 'shared/warningWithoutStack';
import enqueueTask from 'shared/enqueueTask';
const {ReactShouldWarnActingUpdates} = ReactSharedInternals;
// this implementation should be exactly the same in
// ReactTestUtilsAct.js, ReactTestRendererAct.js, createReactNoop.js
// we track the 'depth' of the act() calls with this counter,
// so we can tell if any async act() calls try to run in parallel.
let actingUpdatesScopeDepth = 0;
function flushEffectsAndMicroTasks(onDone: (err: ?Error) => void) {
try {
flushPassiveEffects();
enqueueTask(() => {
if (flushPassiveEffects()) {
flushEffectsAndMicroTasks(onDone);
} else {
onDone();
}
});
} catch (err) {
onDone(err);
}
}
function act(callback: () => Thenable) {
let previousActingUpdatesScopeDepth;
if (__DEV__) {
previousActingUpdatesScopeDepth = actingUpdatesScopeDepth;
actingUpdatesScopeDepth++;
ReactShouldWarnActingUpdates.current = true;
}
function onDone() {
if (__DEV__) {
actingUpdatesScopeDepth--;
if (actingUpdatesScopeDepth === 0) {
ReactShouldWarnActingUpdates.current = false;
}
if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) {
// if it's _less than_ previousActingUpdatesScopeDepth, then we can assume the 'other' one has warned
warningWithoutStack(
null,
'You seem to have overlapping act() calls, this is not supported. ' +
'Be sure to await previous act() calls before making a new one. ',
);
}
}
}
const result = batchedUpdates(callback);
if (
result !== null &&
typeof result === 'object' &&
typeof result.then === 'function'
) {
// setup a boolean that gets set to true only
// once this act() call is await-ed
let called = false;
if (__DEV__) {
if (typeof Promise !== 'undefined') {
//eslint-disable-next-line no-undef
Promise.resolve()
.then(() => {})
.then(() => {
if (called === false) {
warningWithoutStack(
null,
'You called act(async () => ...) without await. ' +
'This could lead to unexpected testing behaviour, interleaving multiple act ' +
'calls and mixing their scopes. You should - await act(async () => ...);',
);
}
});
}
}
// in the async case, the returned thenable runs the callback, flushes
// effects and microtasks in a loop until flushPassiveEffects() === false,
// and cleans up
return {
then(resolve: () => void, reject: (?Error) => void) {
called = true;
result.then(
() => {
flushEffectsAndMicroTasks((err: ?Error) => {
onDone();
if (err) {
reject(err);
} else {
resolve();
}
});
},
err => {
onDone();
reject(err);
},
);
},
};
} else {
if (__DEV__) {
warningWithoutStack(
result === undefined,
'The callback passed to act(...) function ' +
'must return undefined, or a Promise. You returned %s',
result,
);
}
// flush effects until none remain, and cleanup
try {
while (flushPassiveEffects()) {}
onDone();
} catch (err) {
onDone();
throw err;
}
// in the sync case, the returned thenable only warns *if* await-ed
return {
then(resolve: () => void) {
if (__DEV__) {
warningWithoutStack(
false,
'Do not await the result of calling act(...) with sync logic, it is not a Promise.',
);
}
resolve();
},
};
}
}
export default act;

View File

@ -1023,27 +1023,7 @@ describe('ReactTestRenderer', () => {
ReactTestRenderer.create(<App />);
});
describe('act', () => {
it('can use .act() to batch updates and effects', () => {
function App(props) {
React.useEffect(() => {
props.callback();
});
return null;
}
let called = false;
ReactTestRenderer.act(() => {
ReactTestRenderer.create(
<App
callback={() => {
called = true;
}}
/>,
);
});
expect(called).toBe(true);
});
// we run this test here because we need a dom-less scope
it('warns and throws if you use TestUtils.act instead of TestRenderer.act in node', () => {
// we warn when you try to load 2 renderers in the same 'scope'
// so as suggested, we call resetModules() to carry on with the test
@ -1053,10 +1033,9 @@ describe('ReactTestRenderer', () => {
expect(() => act(() => {})).toThrow('document is not defined');
}).toWarnDev(
[
'It looks like you called TestUtils.act(...) in a non-browser environment',
'It looks like you called ReactTestUtils.act(...) in a non-browser environment',
],
{withoutStack: 1},
);
});
});
});

View File

@ -0,0 +1,122 @@
jest.useRealTimers();
let React;
let ReactTestRenderer;
let Scheduler;
let act;
describe('ReactTestRenderer.act()', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactTestRenderer = require('react-test-renderer');
Scheduler = require('scheduler');
act = ReactTestRenderer.act;
});
it('can use .act() to flush effects', () => {
function App(props) {
let [ctr, setCtr] = React.useState(0);
React.useEffect(() => {
props.callback();
setCtr(1);
}, []);
return ctr;
}
let calledLog = [];
let root;
act(() => {
root = ReactTestRenderer.create(
<App
callback={() => {
calledLog.push(calledLog.length);
}}
/>,
);
});
expect(calledLog).toEqual([0]);
expect(root.toJSON()).toEqual('1');
});
it("warns if you don't use .act", () => {
let setCtr;
function App(props) {
let [ctr, _setCtr] = React.useState(0);
setCtr = _setCtr;
return ctr;
}
ReactTestRenderer.create(<App />);
expect(() => {
setCtr(1);
}).toWarnDev([
'An update to App inside a test was not wrapped in act(...)',
]);
});
describe('async', () => {
it('should work with async/await', async () => {
function fetch(url) {
return Promise.resolve({
details: [1, 2, 3],
});
}
function App() {
let [details, setDetails] = React.useState(0);
React.useEffect(() => {
async function fetchDetails() {
const response = await fetch();
setDetails(response.details);
}
fetchDetails();
}, []);
return details;
}
let root;
await ReactTestRenderer.act(async () => {
root = ReactTestRenderer.create(<App />);
});
expect(root.toJSON()).toEqual(['1', '2', '3']);
});
it('should not flush effects without also flushing microtasks', async () => {
const {useEffect, useReducer} = React;
const alreadyResolvedPromise = Promise.resolve();
function App() {
// This component will keep updating itself until step === 3
const [step, proceed] = useReducer(s => (s === 3 ? 3 : s + 1), 1);
useEffect(() => {
Scheduler.yieldValue('Effect');
alreadyResolvedPromise.then(() => {
Scheduler.yieldValue('Microtask');
proceed();
});
});
return step;
}
const root = ReactTestRenderer.create(null);
await act(async () => {
root.update(<App />);
});
expect(Scheduler).toHaveYielded([
// Should not flush effects without also flushing microtasks
// First render:
'Effect',
'Microtask',
// Second render:
'Effect',
'Microtask',
// Final render:
'Effect',
'Microtask',
]);
expect(root).toMatchRenderedOutput('3');
});
});
});

View File

@ -15,6 +15,8 @@ import ReactDebugCurrentFrame from './ReactDebugCurrentFrame';
const ReactSharedInternals = {
ReactCurrentDispatcher,
ReactCurrentOwner,
// used by act()
ReactShouldWarnActingUpdates: {current: false},
// Used by renderers to avoid bundling object-assign twice in UMD bundles:
assign,
};

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import warningWithoutStack from './warningWithoutStack';
let didWarnAboutMessageChannel = false;
let enqueueTask;
try {
// assuming we're in node, let's try to get node's
// version of setImmediate, bypassing fake timers if any
let r = require; // trick packagers not to bundle this stuff.
enqueueTask = r('timers').setImmediate;
} catch (_err) {
// we're in a browser
// we can't use regular timers because they may still be faked
// so we try MessageChannel+postMessage instead
enqueueTask = function(callback: () => void) {
if (__DEV__) {
if (didWarnAboutMessageChannel === false) {
didWarnAboutMessageChannel = true;
warningWithoutStack(
typeof MessageChannel !== 'undefined',
'This browser does not have a MessageChannel implementation, ' +
'so enqueuing tasks via await act(async () => ...) will fail. ' +
'Please file an issue at https://github.com/facebook/react/issues ' +
'if you encounter this warning.',
);
}
}
const channel = new MessageChannel();
channel.port1.onmessage = callback;
channel.port2.postMessage(undefined);
};
}
export default enqueueTask;

File diff suppressed because it is too large Load Diff