Skip to content

Commit e5ecc52

Browse files
committed
Implement support for nested calls to act
When calls to act are nested, effects and component updates are only flushed when the outer `act` call returns, as per [1] and [2]. This is convenient for creating helper functions which may invoke `act` themselves. [1] facebook/react#15682 [2] facebook/react#15472
1 parent 5658e22 commit e5ecc52

File tree

2 files changed

+128
-4
lines changed

2 files changed

+128
-4
lines changed

test-utils/src/index.js

+34-3
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,37 @@ 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+
++actDepth;
29+
if (actDepth > 1) {
30+
// If calls to `act` are nested, a flush happens only when the
31+
// outermost call returns. In the inner call, we just execute the
32+
// callback and return since the infrastructure for flushing has already
33+
// been set up.
34+
const result = cb();
35+
if (isThenable(result)) {
36+
return result.then(() => {
37+
--actDepth;
38+
});
39+
}
40+
--actDepth;
41+
return Promise.resolve();
42+
}
43+
1844
const previousRequestAnimationFrame = options.requestAnimationFrame;
1945
const rerender = setupRerender();
2046

@@ -36,14 +62,19 @@ export function act(cb) {
3662

3763
teardown();
3864
options.requestAnimationFrame = previousRequestAnimationFrame;
65+
66+
--actDepth;
3967
};
4068

4169
const result = cb();
4270

43-
if (result != null && typeof result.then === 'function') {
71+
if (isThenable(result)) {
4472
return result.then(finish);
4573
}
4674

75+
// nb. If the callback is synchronous, effects must be flushed before
76+
// `act` returns, so that the caller does not have to await the result,
77+
// even though React recommends this.
4778
finish();
4879
return Promise.resolve();
4980
}

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

+94-1
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';
@@ -238,4 +238,97 @@ describe('act', () => {
238238
'act result resolved'
239239
]);
240240
});
241+
242+
context('when `act` calls are nested', () => {
243+
it('should invoke nested sync callback and return a Promise', () => {
244+
let innerResult;
245+
const spy = sinon.stub();
246+
247+
act(() => {
248+
innerResult = act(spy);
249+
});
250+
251+
expect(spy).to.be.calledOnce;
252+
expect(innerResult.then).to.be.a('function');
253+
});
254+
255+
it('should invoke nested async callback and return a Promise', async () => {
256+
const events = [];
257+
258+
await act(async () => {
259+
events.push('began outer act callback');
260+
await act(async () => {
261+
events.push('began inner act callback');
262+
await Promise.resolve();
263+
events.push('end inner act callback');
264+
});
265+
events.push('end outer act callback');
266+
});
267+
events.push('act finished');
268+
269+
expect(events).to.deep.equal([
270+
'began outer act callback',
271+
'began inner act callback',
272+
'end inner act callback',
273+
'end outer act callback',
274+
'act finished'
275+
]);
276+
});
277+
278+
it('should only flush effects when outer `act` call returns', () => {
279+
let counter = 0;
280+
281+
function Widget() {
282+
useEffect(() => {
283+
++counter;
284+
});
285+
const [, forceUpdate] = useReducer(x => x + 1, 0);
286+
return <button onClick={forceUpdate}>test</button>;
287+
}
288+
289+
act(() => {
290+
render(<Widget />, scratch);
291+
const button = scratch.querySelector('button');
292+
expect(counter).to.equal(0);
293+
294+
act(() => {
295+
button.dispatchEvent(new Event('click'));
296+
});
297+
298+
// Effect triggered by inner `act` call should not have been
299+
// flushed yet.
300+
expect(counter).to.equal(0);
301+
});
302+
303+
// Effects triggered by inner `act` call should not have been
304+
// flushed yet.
305+
expect(counter).to.equal(2);
306+
});
307+
308+
it('should only flush updates when outer `act` call returns', () => {
309+
function Button() {
310+
const [count, setCount] = useState(0);
311+
const increment = () => setCount(count => count + 1);
312+
return <button onClick={increment}>{count}</button>;
313+
}
314+
315+
render(<Button />, scratch);
316+
const button = scratch.querySelector('button');
317+
expect(button.textContent).to.equal('0');
318+
319+
act(() => {
320+
act(() => {
321+
button.dispatchEvent(new Event('click'));
322+
});
323+
324+
// Update triggered by inner `act` call should not have been
325+
// flushed yet.
326+
expect(button.textContent).to.equal('0');
327+
});
328+
329+
// Updates from outer and inner `act` calls should now have been
330+
// flushed.
331+
expect(button.textContent).to.equal('1');
332+
});
333+
});
241334
});

0 commit comments

Comments
 (0)