/**
* 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 Scheduler;
let act;
let container;
jest.useRealTimers();
function sleep(period) {
return new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, period);
});
}
describe('ReactTestUtils.act()', () => {
// first we run all the tests with concurrent mode
if (__EXPERIMENTAL__) {
let concurrentRoot = null;
const renderConcurrent = (el, dom) => {
concurrentRoot = ReactDOM.unstable_createRoot(dom);
concurrentRoot.render(el);
};
const unmountConcurrent = _dom => {
if (concurrentRoot !== null) {
concurrentRoot.unmount();
concurrentRoot = null;
}
};
const rerenderConcurrent = el => {
concurrentRoot.render(el);
};
runActTests(
'concurrent mode',
renderConcurrent,
unmountConcurrent,
rerenderConcurrent,
);
}
// and then in legacy mode
let legacyDom = null;
function renderLegacy(el, dom) {
legacyDom = dom;
ReactDOM.render(el, dom);
}
function unmountLegacy(dom) {
legacyDom = null;
ReactDOM.unmountComponentAtNode(dom);
}
function rerenderLegacy(el) {
ReactDOM.render(el, legacyDom);
}
runActTests('legacy mode', renderLegacy, unmountLegacy, rerenderLegacy);
describe('unacted effects', () => {
function App() {
React.useEffect(() => {}, []);
return null;
}
it('does not warn in legacy mode', () => {
expect(() => {
ReactDOM.render(, document.createElement('div'));
}).toErrorDev([]);
});
it('warns in strict mode', () => {
expect(() => {
ReactDOM.render(
,
document.createElement('div'),
);
}).toErrorDev([
'An update to App ran an effect, but was not wrapped in act(...)',
]);
});
// @gate experimental
it('warns in concurrent mode', () => {
expect(() => {
const root = ReactDOM.unstable_createRoot(
document.createElement('div'),
);
root.render();
Scheduler.unstable_flushAll();
}).toErrorDev([
'An update to App ran an effect, but was not wrapped in act(...)',
]);
});
});
});
function runActTests(label, render, unmount, rerender) {
describe(label, () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactTestUtils = require('react-dom/test-utils');
Scheduler = require('scheduler');
act = ReactTestUtils.act;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
unmount(container);
document.body.removeChild(container);
});
describe('sync', () => {
it('can use act to flush effects', () => {
function App() {
React.useEffect(() => {
Scheduler.unstable_yieldValue(100);
});
return null;
}
act(() => {
render(, container);
});
expect(Scheduler).toHaveYielded([100]);
});
it('flushes effects on every call', async () => {
function App() {
const [ctr, setCtr] = React.useState(0);
React.useEffect(() => {
Scheduler.unstable_yieldValue(ctr);
});
return (
);
}
act(() => {
render(, container);
});
expect(Scheduler).toHaveYielded([0]);
const button = container.querySelector('#button');
function click() {
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
}
await act(async () => {
click();
click();
click();
});
// it consolidates the 3 updates, then fires the effect
expect(Scheduler).toHaveYielded([3]);
await act(async () => click());
expect(Scheduler).toHaveYielded([4]);
await act(async () => click());
expect(Scheduler).toHaveYielded([5]);
expect(button.innerHTML).toBe('5');
});
it("should keep flushing effects until they're done", () => {
function App() {
const [ctr, setCtr] = React.useState(0);
React.useEffect(() => {
if (ctr < 5) {
setCtr(x => x + 1);
}
});
return ctr;
}
act(() => {
render(, container);
});
expect(container.innerHTML).toBe('5');
});
it('should flush effects only on exiting the outermost act', () => {
function App() {
React.useEffect(() => {
Scheduler.unstable_yieldValue(0);
});
return null;
}
// let's nest a couple of act() calls
act(() => {
act(() => {
render(, container);
});
// the effect wouldn't have yielded yet because
// we're still inside an act() scope
expect(Scheduler).toHaveYielded([]);
});
// but after exiting the last one, effects get flushed
expect(Scheduler).toHaveYielded([0]);
});
it('warns if a setState is called outside of act(...)', () => {
let setValue = null;
function App() {
const [value, _setValue] = React.useState(0);
setValue = _setValue;
return value;
}
act(() => {
render(, container);
});
expect(() => setValue(1)).toErrorDev([
'An update to App inside a test was not wrapped in act(...).',
]);
});
describe('fake timers', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('lets a ticker update', () => {
function App() {
const [toggle, setToggle] = React.useState(0);
React.useEffect(() => {
const timeout = setTimeout(() => {
setToggle(1);
}, 200);
return () => clearTimeout(timeout);
}, []);
return toggle;
}
act(() => {
render(, container);
});
act(() => {
jest.runAllTimers();
});
expect(container.innerHTML).toBe('1');
});
it('can use the async version to catch microtasks', async () => {
function App() {
const [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;
}
act(() => {
render(, 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() {
const [state, setState] = React.useState(0);
async function ticker() {
await null;
setState(x => x + 1);
}
React.useEffect(() => {
ticker();
}, [Math.min(state, 4)]);
return state;
}
await act(async () => {
render(, container);
});
// all 5 ticks present and accounted for
expect(container.innerHTML).toBe('5');
});
it('flushes immediate re-renders with act', () => {
function App() {
const [ctr, setCtr] = React.useState(0);
React.useEffect(() => {
if (ctr === 0) {
setCtr(1);
}
const timeout = setTimeout(() => setCtr(2), 1000);
return () => clearTimeout(timeout);
});
return ctr;
}
act(() => {
render(, container);
// Since effects haven't been flushed yet, this does not advance the timer
jest.runAllTimers();
});
expect(container.innerHTML).toBe('1');
act(() => {
jest.runAllTimers();
});
expect(container.innerHTML).toBe('2');
});
});
it('warns if you return a value inside act', () => {
expect(() => act(() => null)).toErrorDev(
[
'The callback passed to act(...) function must return undefined, or a Promise.',
],
{withoutStack: true},
);
expect(() => act(() => 123)).toErrorDev(
[
'The callback passed to act(...) function must return undefined, or a Promise.',
],
{withoutStack: true},
);
});
it('warns if you try to await a sync .act call', () => {
expect(() => act(() => {}).then(() => {})).toErrorDev(
[
'Do not await the result of calling act(...) with sync logic, it is not a Promise.',
],
{withoutStack: true},
);
});
});
describe('asynchronous tests', () => {
it('works with timeouts', async () => {
function App() {
const [ctr, setCtr] = React.useState(0);
function doSomething() {
setTimeout(() => {
setCtr(1);
}, 50);
}
React.useEffect(() => {
doSomething();
}, []);
return ctr;
}
await act(async () => {
render(, container);
// flush a little to start the timer
expect(Scheduler).toFlushAndYield([]);
await sleep(100);
});
expect(container.innerHTML).toBe('1');
});
it('flushes microtasks before exiting', async () => {
function App() {
const [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;
}
await act(async () => {
render(, container);
});
expect(container.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('async commits and effects are guaranteed to be flushed', async () => {
function App() {
const [state, setState] = React.useState(0);
async function something() {
await null;
setState(1);
}
React.useEffect(() => {
something();
}, []);
React.useEffect(() => {
Scheduler.unstable_yieldValue(state);
});
return state;
}
await act(async () => {
render(, container);
});
// exiting act() drains effects and microtasks
expect(Scheduler).toHaveYielded([0, 1]);
expect(container.innerHTML).toBe('1');
});
it('can handle cascading promises', async () => {
// this component triggers an effect, that waits a tick,
// then sets state. repeats this 5 times.
function App() {
const [state, setState] = React.useState(0);
async function ticker() {
await null;
setState(x => x + 1);
}
React.useEffect(() => {
Scheduler.unstable_yieldValue(state);
ticker();
}, [Math.min(state, 4)]);
return state;
}
await act(async () => {
render(, container);
});
// all 5 ticks present and accounted for
expect(Scheduler).toHaveYielded([0, 1, 2, 3, 4]);
expect(container.innerHTML).toBe('5');
});
});
describe('error propagation', () => {
it('propagates errors - sync', () => {
let err;
try {
act(() => {
throw new Error('some error');
});
} catch (_err) {
err = _err;
} finally {
expect(err instanceof Error).toBe(true);
expect(err.message).toBe('some error');
}
});
it('should propagate errors from effects - sync', () => {
function App() {
React.useEffect(() => {
throw new Error('oh no');
});
return null;
}
let error;
try {
act(() => {
render(, container);
});
} catch (_error) {
error = _error;
} finally {
expect(error instanceof Error).toBe(true);
expect(error.message).toBe('oh no');
}
});
it('propagates errors - async', async () => {
let err;
try {
await act(async () => {
await sleep(100);
throw new Error('some error');
});
} catch (_err) {
err = _err;
} finally {
expect(err instanceof Error).toBe(true);
expect(err.message).toBe('some error');
}
});
it('should cleanup after errors - sync', () => {
function App() {
React.useEffect(() => {
Scheduler.unstable_yieldValue('oh yes');
});
return null;
}
let error;
try {
act(() => {
throw new Error('oh no');
});
} catch (_error) {
error = _error;
} finally {
expect(error instanceof Error).toBe(true);
expect(error.message).toBe('oh no');
// should be able to render components after this tho
act(() => {
render(, container);
});
expect(Scheduler).toHaveYielded(['oh yes']);
}
});
it('should cleanup after errors - async', async () => {
function App() {
async function somethingAsync() {
await null;
Scheduler.unstable_yieldValue('oh yes');
}
React.useEffect(() => {
somethingAsync();
});
return null;
}
let error;
try {
await act(async () => {
await sleep(100);
throw new Error('oh no');
});
} catch (_error) {
error = _error;
} finally {
expect(error instanceof Error).toBe(true);
expect(error.message).toBe('oh no');
// should be able to render components after this tho
await act(async () => {
render(, container);
});
expect(Scheduler).toHaveYielded(['oh yes']);
}
});
});
describe('suspense', () => {
if (__DEV__ && __EXPERIMENTAL__) {
// todo - remove __DEV__ check once we start using testing builds
it('triggers fallbacks if available', async () => {
if (label !== 'legacy mode') {
// FIXME: Support for Concurrent Root intentionally removed
// from the public version of `act`. It will be added back in
// a future major version, before the Concurrent Root is released.
// Consider skipping all non-Legacy tests in this suite until then.
return;
}
let resolved = false;
let resolve;
const promise = new Promise(_resolve => {
resolve = _resolve;
});
function Suspends() {
if (resolved) {
return 'was suspended';
}
throw promise;
}
function App(props) {
return (
loading...}>
{props.suspend ? : 'content'}
);
}
// render something so there's content
act(() => {
render(, container);
});
// trigger a suspendy update
act(() => {
rerender();
});
expect(
document.querySelector('[data-test-id=spinner]'),
).not.toBeNull();
// now render regular content again
act(() => {
rerender();
});
expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
// trigger a suspendy update with a delay
React.unstable_startTransition(() => {
act(() => {
rerender();
});
});
if (label === 'concurrent mode') {
// In Concurrent Mode, refresh transitions delay indefinitely.
expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
} else {
// In Legacy Mode, all fallbacks are forced to display,
// even during a refresh transition.
expect(
document.querySelector('[data-test-id=spinner]'),
).not.toBeNull();
}
// resolve the promise
await act(async () => {
resolved = true;
resolve();
});
// spinner gone, content showing
expect(document.querySelector('[data-test-id=spinner]')).toBeNull();
expect(container.textContent).toBe('was suspended');
});
}
});
describe('warn in prod mode', () => {
it('warns if you try to use act() in prod mode', () => {
const spy = spyOnDevAndProd(console, 'error');
act(() => {});
if (!__DEV__) {
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error.calls.argsFor(0)[0]).toContain(
'act(...) is not supported in production builds of React',
);
} else {
expect(console.error).toHaveBeenCalledTimes(0);
}
spy.calls.reset();
// does not warn twice
act(() => {});
expect(console.error).toHaveBeenCalledTimes(0);
});
});
});
}