Skip to content

Commit aed0e1c

Browse files
author
Sunil Pai
authored
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
1 parent 4c75881 commit aed0e1c

25 files changed

+1639
-600
lines changed

fixtures/dom/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public/react-dom.development.js
1414
public/react-dom.production.min.js
1515
public/react-dom-server.browser.development.js
1616
public/react-dom-server.browser.production.min.js
17+
public/react-dom-test-utils.development.js
18+
public/react-dom-test-utils.production.min.js
1719

1820
# misc
1921
.DS_Store

fixtures/dom/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
},
1919
"scripts": {
2020
"start": "react-scripts start",
21-
"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/",
21+
"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/",
2222
"build": "react-scripts build && cp build/index.html build/200.html",
2323
"test": "react-scripts test --env=jsdom",
2424
"eject": "react-scripts eject"

fixtures/dom/public/act-dom.html

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>sanity test for ReactTestUtils.act</title>
5+
</head>
6+
<body>
7+
this page tests whether act runs properly in a browser.
8+
<br/>
9+
your console should say "5"
10+
<script src='react.development.js'></script>
11+
<script src='react-dom.development.js'></script>
12+
<script src='react-dom-test-utils.development.js'></script>
13+
<script>
14+
async function run(){
15+
// from ReactTestUtilsAct-test.js
16+
function App() {
17+
let [state, setState] = React.useState(0);
18+
async function ticker() {
19+
await null;
20+
setState(x => x + 1);
21+
}
22+
React.useEffect(
23+
() => {
24+
ticker();
25+
},
26+
[Math.min(state, 4)],
27+
);
28+
return state;
29+
}
30+
const el = document.createElement('div');
31+
await ReactTestUtils.act(async () => {
32+
ReactDOM.render(React.createElement(App), el);
33+
});
34+
// all 5 ticks present and accounted for
35+
console.log(el.innerHTML);
36+
}
37+
run();
38+
39+
</script>
40+
</body>
41+
</html>

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

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

1918
function getTestDocument(markup) {
2019
const doc = document.implementation.createHTMLDocument('');
@@ -34,7 +33,6 @@ describe('ReactTestUtils', () => {
3433
ReactDOM = require('react-dom');
3534
ReactDOMServer = require('react-dom/server');
3635
ReactTestUtils = require('react-dom/test-utils');
37-
act = ReactTestUtils.act;
3836
});
3937

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

0 commit comments

Comments
 (0)