Skip to content

Commit 4b9e44b

Browse files
authored
Merge pull request #1854 from preactjs/async-act
Implement support for async `act` callbacks and nested calls to `act`
2 parents 00a7289 + 104b02d commit 4b9e44b

File tree

4 files changed

+194
-14
lines changed

4 files changed

+194
-14
lines changed

.babelrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
],
1919
"plugins": [
2020
"transform-object-rest-spread",
21-
"transform-react-jsx"
21+
"transform-react-jsx",
22+
"transform-async-to-promises"
2223
]
2324
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"babel-core": "6.26.3",
114114
"babel-loader": "7.1.5",
115115
"babel-plugin-istanbul": "5.0.1",
116+
"babel-plugin-transform-async-to-promises": "^0.8.14",
116117
"babel-plugin-transform-object-rest-spread": "^6.23.0",
117118
"babel-plugin-transform-react-jsx": "^6.24.1",
118119
"babel-preset-env": "^1.6.1",

test-utils/src/index.js

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,36 @@ export function setupRerender() {
1010
return () => options.__test__drainQueue && options.__test__drainQueue();
1111
}
1212

13+
const isThenable = value => value != null && typeof value.then === 'function';
14+
15+
/** Depth of nested calls to `act`. */
16+
let actDepth = 0;
17+
1318
/**
14-
* Run a test function, and flush all effects and rerenders after invoking it
15-
* @param {() => void} cb The function under test
19+
* Run a test function, and flush all effects and rerenders after invoking it.
20+
*
21+
* Returns a Promise which resolves "immediately" if the callback is
22+
* synchronous or when the callback's result resolves if it is asynchronous.
23+
*
24+
* @param {() => void|Promise<void>} cb The function under test. This may be sync or async.
25+
* @return {Promise<void>}
1626
*/
1727
export function act(cb) {
28+
if (++actDepth > 1) {
29+
// If calls to `act` are nested, a flush happens only when the
30+
// outermost call returns. In the inner call, we just execute the
31+
// callback and return since the infrastructure for flushing has already
32+
// been set up.
33+
const result = cb();
34+
if (isThenable(result)) {
35+
return result.then(() => {
36+
--actDepth;
37+
});
38+
}
39+
--actDepth;
40+
return Promise.resolve();
41+
}
42+
1843
const previousRequestAnimationFrame = options.requestAnimationFrame;
1944
const rerender = setupRerender();
2045

@@ -24,20 +49,33 @@ export function act(cb) {
2449
// Override requestAnimationFrame so we can flush pending hooks.
2550
options.requestAnimationFrame = (fc) => flush = fc;
2651

27-
// Execute the callback we were passed.
28-
cb();
29-
rerender();
52+
const finish = () => {
53+
rerender();
54+
while (flush) {
55+
toFlush = flush;
56+
flush = null;
3057

31-
while (flush) {
32-
toFlush = flush;
33-
flush = null;
58+
toFlush();
59+
rerender();
60+
}
3461

35-
toFlush();
36-
rerender();
62+
teardown();
63+
options.requestAnimationFrame = previousRequestAnimationFrame;
64+
65+
--actDepth;
66+
};
67+
68+
const result = cb();
69+
70+
if (isThenable(result)) {
71+
return result.then(finish);
3772
}
3873

39-
teardown();
40-
options.requestAnimationFrame = previousRequestAnimationFrame;
74+
// nb. If the callback is synchronous, effects must be flushed before
75+
// `act` returns, so that the caller does not have to await the result,
76+
// even though React recommends this.
77+
finish();
78+
return Promise.resolve();
4179
}
4280

4381
/**

test-utils/test/shared/act.test.js

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { options, createElement as h, render } from 'preact';
2-
import { useEffect, useState } from 'preact/hooks';
2+
import { useEffect, useReducer, useState } from 'preact/hooks';
33

44
import { setupScratch, teardown } from '../../../test/_util/helpers';
55
import { act } from '../../src';
@@ -200,4 +200,144 @@ describe('act', () => {
200200
});
201201
expect(scratch.firstChild.textContent).to.equal('1');
202202
});
203+
204+
it('returns a Promise if invoked with a sync callback', () => {
205+
const result = act(() => {});
206+
expect(result.then).to.be.a('function');
207+
return result;
208+
});
209+
210+
it('returns a Promise if invoked with an async callback', () => {
211+
const result = act(async () => {});
212+
expect(result.then).to.be.a('function');
213+
return result;
214+
});
215+
216+
it('should await "thenable" result of callback before flushing', async () => {
217+
const events = [];
218+
219+
function TestComponent() {
220+
useEffect(() => {
221+
events.push('flushed effect');
222+
}, []);
223+
events.push('scheduled effect');
224+
return <div>Test</div>;
225+
}
226+
227+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
228+
229+
events.push('began test');
230+
const acted = act(async () => {
231+
events.push('began act callback');
232+
await delay(1);
233+
render(<TestComponent />, scratch);
234+
events.push('end act callback');
235+
});
236+
events.push('act returned');
237+
await acted;
238+
events.push('act result resolved');
239+
240+
expect(events).to.deep.equal([
241+
'began test',
242+
'began act callback',
243+
'act returned',
244+
'scheduled effect',
245+
'end act callback',
246+
'flushed effect',
247+
'act result resolved'
248+
]);
249+
});
250+
251+
context('when `act` calls are nested', () => {
252+
it('should invoke nested sync callback and return a Promise', () => {
253+
let innerResult;
254+
const spy = sinon.stub();
255+
256+
act(() => {
257+
innerResult = act(spy);
258+
});
259+
260+
expect(spy).to.be.calledOnce;
261+
expect(innerResult.then).to.be.a('function');
262+
});
263+
264+
it('should invoke nested async callback and return a Promise', async () => {
265+
const events = [];
266+
267+
await act(async () => {
268+
events.push('began outer act callback');
269+
await act(async () => {
270+
events.push('began inner act callback');
271+
await Promise.resolve();
272+
events.push('end inner act callback');
273+
});
274+
events.push('end outer act callback');
275+
});
276+
events.push('act finished');
277+
278+
expect(events).to.deep.equal([
279+
'began outer act callback',
280+
'began inner act callback',
281+
'end inner act callback',
282+
'end outer act callback',
283+
'act finished'
284+
]);
285+
});
286+
287+
it('should only flush effects when outer `act` call returns', () => {
288+
let counter = 0;
289+
290+
function Widget() {
291+
useEffect(() => {
292+
++counter;
293+
});
294+
const [, forceUpdate] = useReducer(x => x + 1, 0);
295+
return <button onClick={forceUpdate}>test</button>;
296+
}
297+
298+
act(() => {
299+
render(<Widget />, scratch);
300+
const button = scratch.querySelector('button');
301+
expect(counter).to.equal(0);
302+
303+
act(() => {
304+
button.dispatchEvent(createEvent('click'));
305+
});
306+
307+
// Effect triggered by inner `act` call should not have been
308+
// flushed yet.
309+
expect(counter).to.equal(0);
310+
});
311+
312+
// Effects triggered by inner `act` call should now have been
313+
// flushed.
314+
expect(counter).to.equal(2);
315+
});
316+
317+
it('should only flush updates when outer `act` call returns', () => {
318+
function Button() {
319+
const [count, setCount] = useState(0);
320+
const increment = () => setCount(count => count + 1);
321+
return <button onClick={increment}>{count}</button>;
322+
}
323+
324+
render(<Button />, scratch);
325+
const button = scratch.querySelector('button');
326+
expect(button.textContent).to.equal('0');
327+
328+
act(() => {
329+
act(() => {
330+
button.dispatchEvent(createEvent('click'));
331+
});
332+
333+
// Update triggered by inner `act` call should not have been
334+
// flushed yet.
335+
expect(button.textContent).to.equal('0');
336+
});
337+
338+
// Updates from outer and inner `act` calls should now have been
339+
// flushed.
340+
expect(button.textContent).to.equal('1');
341+
});
342+
});
203343
});

0 commit comments

Comments
 (0)