Skip to content

Commit 267ed98

Browse files
author
Sunil Pai
authored
expose TestUtils.act() for batching actions in tests (#14744)
* expose unstable_interact for batching actions in tests * move to TestUtils * move it all into testutils * s/interact/act * warn when calling hook-like setState outside batching mode * pass tests * merge-temp * move jsdom test to callsite * mark failing tests * pass most tests (except one) * augh IE * pass fuzz tests * better warning, expose the right batchedUpdates on TestRenderer for www * move it into hooks, test for dom * expose a flag on the host config, move stuff around * rename, pass flow * pass flow... again * tweak .act() type * enable for all jest environments/renderers; pass (most) tests. * pass all tests * expose just the warning from the scheduler * don't return values * a bunch of changes. can't return values from .act don't try to await .act calls pass tests * fixes and nits * "fire events that udpates state" * nit * 🙄 * my bad * hi andrew (prettier fix)
1 parent fb3f7bf commit 267ed98

13 files changed

Lines changed: 583 additions & 112 deletions

‎packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
letReact;
1414
letReactTestRenderer;
1515
letReactDebugTools;
16+
letact;
1617

17-
describe('ReactHooksInspectionIntergration',()=>{
18+
describe('ReactHooksInspectionIntegration',()=>{
1819
beforeEach(()=>{
1920
jest.resetModules();
2021
React=require('react');
2122
ReactTestRenderer=require('react-test-renderer');
23+
act=ReactTestRenderer.act;
2224
ReactDebugTools=require('react-debug-tools');
2325
});
2426

@@ -47,7 +49,7 @@ describe('ReactHooksInspectionIntergration', () => {
4749
onMouseUp: setStateB,
4850
}=renderer.root.findByType('div').props;
4951

50-
setStateA('Hi');
52+
act(()=>setStateA('Hi'));
5153

5254
childFiber=renderer.root.findByType(Foo)._currentFiber();
5355
tree=ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -57,7 +59,7 @@ describe('ReactHooksInspectionIntergration', () => {
5759
{name: 'State',value: 'world',subHooks: []},
5860
]);
5961

60-
setStateB('world!');
62+
act(()=>setStateB('world!'));
6163

6264
childFiber=renderer.root.findByType(Foo)._currentFiber();
6365
tree=ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -91,8 +93,12 @@ describe('ReactHooksInspectionIntergration', () => {
9193
React.useMemo(()=>state1+state2,[state1]);
9294

9395
functionupdate(){
94-
setState('A');
95-
dispatch({value: 'B'});
96+
act(()=>{
97+
setState('A');
98+
});
99+
act(()=>{
100+
dispatch({value: 'B'});
101+
});
96102
ref.current='C';
97103
}
98104
letmemoizedUpdate=React.useCallback(update,[]);

‎packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ let React;
1313
letReactDOM;
1414
letSuspense;
1515
letReactCache;
16+
letReactTestUtils;
1617
letTextResource;
18+
letact;
1719

1820
describe('ReactDOMSuspensePlaceholder',()=>{
1921
letcontainer;
@@ -23,6 +25,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
2325
React=require('react');
2426
ReactDOM=require('react-dom');
2527
ReactCache=require('react-cache');
28+
ReactTestUtils=require('react-dom/test-utils');
29+
act=ReactTestUtils.act;
2630
Suspense=React.Suspense;
2731
container=document.createElement('div');
2832
document.body.appendChild(container);
@@ -142,12 +146,14 @@ describe('ReactDOMSuspensePlaceholder', () => {
142146
);
143147
}
144148

145-
ReactDOM.render(<App/>,container);
149+
act(()=>{
150+
ReactDOM.render(<App/>,container);
151+
});
146152
expect(container.innerHTML).toEqual(
147153
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
148154
);
149155

150-
setIsVisible(true);
156+
act(()=>setIsVisible(true));
151157
expect(container.innerHTML).toEqual(
152158
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
153159
);

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let React;
1414
letReactDOM;
1515
letReactDOMServer;
1616
letReactTestUtils;
17+
letact;
1718

1819
functiongetTestDocument(markup){
1920
constdoc=document.implementation.createHTMLDocument('');
@@ -33,6 +34,7 @@ describe('ReactTestUtils', () => {
3334
ReactDOM=require('react-dom');
3435
ReactDOMServer=require('react-dom/server');
3536
ReactTestUtils=require('react-dom/test-utils');
37+
act=ReactTestUtils.act;
3638
});
3739

3840
it('Simulate should have locally attached media events',()=>{
@@ -515,4 +517,168 @@ describe('ReactTestUtils', () => {
515517
ReactTestUtils.renderIntoDocument(<Component/>);
516518
expect(mockArgs.length).toEqual(0);
517519
});
520+
521+
it('can use act to batch effects',()=>{
522+
functionApp(props){
523+
React.useEffect(props.callback);
524+
returnnull;
525+
}
526+
constcontainer=document.createElement('div');
527+
document.body.appendChild(container);
528+
529+
try{
530+
letcalled=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+
functionApp(props){
550+
let[ctr,setCtr]=React.useState(0);
551+
React.useEffect(()=>{
552+
props.callback(ctr);
553+
});
554+
return(
555+
<buttonid="button"onClick={()=>setCtr(x=>x+1)}>
556+
click me!
557+
</button>
558+
);
559+
}
560+
561+
constcontainer=document.createElement('div');
562+
document.body.appendChild(container);
563+
letcalledCtr=0;
564+
act(()=>{
565+
ReactDOM.render(
566+
<App
567+
callback={val=>{
568+
calledCtr=val;
569+
}}
570+
/>,
571+
container,
572+
);
573+
});
574+
constbutton=document.getElementById('button');
575+
functionclick(){
576+
button.dispatchEvent(newMouseEvent('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+
functionApp(){
595+
let[ctr,setCtr]=React.useState(0);
596+
return(
597+
<buttonid="button"onClick={()=>setCtr(x=>x+1)}>
598+
{ctr}
599+
</button>
600+
);
601+
}
602+
constcontainer=document.createElement('div');
603+
document.body.appendChild(container);
604+
letbutton;
605+
act(()=>{
606+
ReactDOM.render(<App/>,container);
607+
});
608+
button=document.getElementById('button');
609+
expect(button.innerHTML).toBe('0');
610+
act(()=>{
611+
button.dispatchEvent(newMouseEvent('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+
letsetValueRef=null;
619+
functionApp(){
620+
let[value,setValue]=React.useState(0);
621+
setValueRef=setValue;
622+
return(
623+
<buttonid="button"onClick={()=>setValue(2)}>
624+
{value}
625+
</button>
626+
);
627+
}
628+
constcontainer=document.createElement('div');
629+
document.body.appendChild(container);
630+
letbutton;
631+
act(()=>{
632+
ReactDOM.render(<App/>,container);
633+
button=container.querySelector('#button');
634+
button.dispatchEvent(newMouseEvent('click',{bubbles: true}));
635+
});
636+
expect(button.innerHTML).toBe('2');
637+
expect(()=>setValueRef(1)).toWarnDev(
638+
['An update to App inside a test was not wrapped in act(...).'],
639+
{withoutStack: 1},
640+
);
641+
document.body.removeChild(container);
642+
});
643+
644+
it('lets a ticker update',()=>{
645+
functionApp(){
646+
let[toggle,setToggle]=React.useState(0);
647+
React.useEffect(()=>{
648+
lettimeout=setTimeout(()=>{
649+
setToggle(1);
650+
},200);
651+
return()=>clearTimeout(timeout);
652+
});
653+
returntoggle;
654+
}
655+
constcontainer=document.createElement('div');
656+
657+
act(()=>{
658+
act(()=>{
659+
ReactDOM.render(<App/>,container);
660+
});
661+
jest.advanceTimersByTime(250);
662+
});
663+
664+
expect(container.innerHTML).toBe('1');
665+
});
666+
667+
it('warns if you return a value inside act',()=>{
668+
expect(()=>act(()=>123)).toWarnDev(
669+
[
670+
'The callback passed to ReactTestUtils.act(...) function must not return anything.',
671+
],
672+
{withoutStack: true},
673+
);
674+
});
675+
676+
it('warns if you try to await an .act call',()=>{
677+
expect(act(()=>{}).then).toWarnDev(
678+
[
679+
'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
680+
],
681+
{withoutStack: true},
682+
);
683+
});
518684
});

‎packages/react-dom/src/test-utils/ReactTestUtils.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ import {
1818
importSyntheticEventfrom'events/SyntheticEvent';
1919
importinvariantfrom'shared/invariant';
2020
importlowPriorityWarningfrom'shared/lowPriorityWarning';
21+
importwarningWithoutStackfrom'shared/warningWithoutStack';
2122
import{ELEMENT_NODE}from'../shared/HTMLNodeType';
2223
import*asDOMTopLevelEventTypesfrom'../events/DOMTopLevelEventTypes';
2324

25+
// for .act's return value
26+
typeThenable={
27+
then(resolve: ()=>mixed,reject?: ()=>mixed): mixed,
28+
};
29+
2430
const{findDOMNode}=ReactDOM;
2531
// Keep in sync with ReactDOMUnstableNativeDependencies.js
2632
// and ReactDOM.js:
@@ -145,6 +151,9 @@ function validateClassInstance(inst, methodName) {
145151
);
146152
}
147153

154+
// stub element used by act() when flushing effects
155+
letactContainerElement=document.createElement('div');
156+
148157
/**
149158
* Utilities for making it easy to test React components.
150159
*
@@ -380,6 +389,43 @@ const ReactTestUtils = {
380389

381390
Simulate: null,
382391
SimulateNative: {},
392+
393+
act(callback: ()=>void): Thenable{
394+
// note: keep these warning messages in sync with
395+
// createReactNoop.js and ReactTestRenderer.js
396+
const result =ReactDOM.unstable_batchedUpdates(callback);
397+
if(__DEV__){
398+
if(result!==undefined){
399+
letaddendum;
400+
if(typeofresult.then==='function'){
401+
addendum=
402+
'\n\nIt looks like you wrote ReactTestUtils.act(async () => ...), '+
403+
'or returned a Promise from the callback passed to it. '+
404+
'Putting asynchronous logic inside ReactTestUtils.act(...) is not supported.\n';
405+
}else{
406+
addendum=' You returned: '+result;
407+
}
408+
warningWithoutStack(
409+
false,
410+
'The callback passed to ReactTestUtils.act(...) function must not return anything.%s',
411+
addendum,
412+
);
413+
}
414+
}
415+
ReactDOM.render(<div/>,actContainerElement);
416+
// we want the user to not expect a return,
417+
// but we want to warn if they use it like they can await on it.
418+
return{
419+
then(){
420+
if(__DEV__){
421+
warningWithoutStack(
422+
false,
423+
'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
424+
);
425+
}
426+
},
427+
};
428+
},
383429
};
384430

385431
/**

‎packages/react-noop-renderer/src/createReactNoop.js‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import type {ReactNodeList} from 'shared/ReactTypes';
2121
import{createPortal}from'shared/ReactPortal';
2222
importexpectfrom'expect';
2323
import{REACT_FRAGMENT_TYPE,REACT_ELEMENT_TYPE}from'shared/ReactSymbols';
24+
importwarningWithoutStackfrom'shared/warningWithoutStack';
25+
26+
// for .act's return value
27+
typeThenable={
28+
then(resolve: ()=>mixed,reject?: ()=>mixed): mixed,
29+
};
2430

2531
typeContainer={
2632
rootID: string,
@@ -864,6 +870,43 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
864870

865871
interactiveUpdates: NoopRenderer.interactiveUpdates,
866872

873+
// maybe this should exist only in the test file
874+
act(callback: ()=>void): Thenable{
875+
// note: keep these warning messages in sync with
876+
// ReactTestRenderer.js and ReactTestUtils.js
877+
let result =NoopRenderer.batchedUpdates(callback);
878+
if(__DEV__){
879+
if(result!==undefined){
880+
letaddendum;
881+
if(typeofresult.then==='function'){
882+
addendum=
883+
"\n\nIt looks like you wrote ReactNoop.act(async () => ...) or returned a Promise from it's callback. "+
884+
'Putting asynchronous logic inside ReactNoop.act(...) is not supported.\n';
885+
}else{
886+
addendum=' You returned: '+result;
887+
}
888+
warningWithoutStack(
889+
false,
890+
'The callback passed to ReactNoop.act(...) function must not return anything.%s',
891+
addendum,
892+
);
893+
}
894+
}
895+
ReactNoop.flushPassiveEffects();
896+
// we want the user to not expect a return,
897+
// but we want to warn if they use it like they can await on it.
898+
return{
899+
then(){
900+
if(__DEV__){
901+
warningWithoutStack(
902+
false,
903+
'Do not await the result of calling ReactNoop.act(...), it is not a Promise.',
904+
);
905+
}
906+
},
907+
};
908+
},
909+
867910
flushSync(fn: ()=>mixed){
868911
yieldedValues=[];
869912
NoopRenderer.flushSync(fn);

0 commit comments

Comments
 (0)