From 066c789ba74d8973a85cab7e0ea37fdbc62ff724 Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Sat, 10 Aug 2019 22:08:10 +0100 Subject: [PATCH 1/4] Enable use of async/await in tests Add a transform to convert async/await to Promises for use in tests. This is the same low-footprint transform used by microbundle. Slack discussion: https://preact.slack.com/archives/G9T60GUQ0/p1565514000140700 --- .babelrc | 3 ++- package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.babelrc b/.babelrc index 1fb20f9b5c..f905c25de6 100644 --- a/.babelrc +++ b/.babelrc @@ -18,6 +18,7 @@ ], "plugins": [ "transform-object-rest-spread", - "transform-react-jsx" + "transform-react-jsx", + "transform-async-to-promises" ] } diff --git a/package.json b/package.json index 7e20b1877f..1bc7a106b0 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "babel-core": "6.26.3", "babel-loader": "7.1.5", "babel-plugin-istanbul": "5.0.1", + "babel-plugin-transform-async-to-promises": "^0.8.14", "babel-plugin-transform-object-rest-spread": "^6.23.0", "babel-plugin-transform-react-jsx": "^6.24.1", "babel-preset-env": "^1.6.1", From 5658e2285d3aabb9595179a65694d37bf112868d Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Sat, 10 Aug 2019 22:13:40 +0100 Subject: [PATCH 2/4] Implement support for using async callbacks with `act` Implement initial support for `act` callbacks returning a Promise, in which case the promise is awaited before state updates and effects are flushed. See https://reactjs.org/blog/2019/08/08/react-v16.9.0.html#async-act-for-testing for more details on the motivation. --- test-utils/src/index.js | 28 +++++++++++------- test-utils/test/shared/act.test.js | 47 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/test-utils/src/index.js b/test-utils/src/index.js index 447f502cfc..9e7a677da2 100644 --- a/test-utils/src/index.js +++ b/test-utils/src/index.js @@ -24,20 +24,28 @@ export function act(cb) { // Override requestAnimationFrame so we can flush pending hooks. options.requestAnimationFrame = (fc) => flush = fc; - // Execute the callback we were passed. - cb(); - rerender(); + const finish = () => { + rerender(); + while (flush) { + toFlush = flush; + flush = null; - while (flush) { - toFlush = flush; - flush = null; + toFlush(); + rerender(); + } - toFlush(); - rerender(); + teardown(); + options.requestAnimationFrame = previousRequestAnimationFrame; + }; + + const result = cb(); + + if (result != null && typeof result.then === 'function') { + return result.then(finish); } - teardown(); - options.requestAnimationFrame = previousRequestAnimationFrame; + finish(); + return Promise.resolve(); } /** diff --git a/test-utils/test/shared/act.test.js b/test-utils/test/shared/act.test.js index f1d3750e9b..bf9c58a0a6 100644 --- a/test-utils/test/shared/act.test.js +++ b/test-utils/test/shared/act.test.js @@ -191,4 +191,51 @@ describe('act', () => { }); expect(scratch.firstChild.textContent).to.equal('1'); }); + + it('returns a Promise if invoked with a sync callback', () => { + const result = act(() => {}); + expect(result.then).to.be.a('function'); + return result; + }); + + it('returns a Promise if invoked with an async callback', () => { + const result = act(async () => {}); + expect(result.then).to.be.a('function'); + return result; + }); + + it('should await "thenable" result of callback before flushing', async () => { + const events = []; + + function TestComponent() { + useEffect(() => { + events.push('flushed effect'); + }, []); + events.push('scheduled effect'); + return
Test
; + } + + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + + events.push('began test'); + const acted = act(async () => { + events.push('began act callback'); + await delay(1); + render(, scratch); + events.push('end act callback'); + }); + events.push('act returned'); + await acted; + events.push('act result resolved'); + + expect(events).to.deep.equal([ + 'began test', + 'began act callback', + 'act returned', + 'scheduled effect', + 'end act callback', + 'flushed effect', + 'act result resolved' + ]); + }); }); From a66efcb6f5862706bbe1a6833029b39074843044 Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Sun, 11 Aug 2019 10:53:37 +0100 Subject: [PATCH 3/4] 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] https://github.com/facebook/react/pull/15682 [2] https://github.com/facebook/react/issues/15472 --- test-utils/src/index.js | 37 +++++++++++- test-utils/test/shared/act.test.js | 95 +++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/test-utils/src/index.js b/test-utils/src/index.js index 9e7a677da2..9d2ee58d15 100644 --- a/test-utils/src/index.js +++ b/test-utils/src/index.js @@ -10,11 +10,37 @@ export function setupRerender() { return () => options.__test__drainQueue && options.__test__drainQueue(); } +const isThenable = value => value != null && typeof value.then === 'function'; + +/** Depth of nested calls to `act`. */ +let actDepth = 0; + /** - * Run a test function, and flush all effects and rerenders after invoking it - * @param {() => void} cb The function under test + * Run a test function, and flush all effects and rerenders after invoking it. + * + * Returns a Promise which resolves "immediately" if the callback is + * synchronous or when the callback's result resolves if it is asynchronous. + * + * @param {() => void|Promise} cb The function under test. This may be sync or async. + * @return {Promise} */ export function act(cb) { + ++actDepth; + if (actDepth > 1) { + // If calls to `act` are nested, a flush happens only when the + // outermost call returns. In the inner call, we just execute the + // callback and return since the infrastructure for flushing has already + // been set up. + const result = cb(); + if (isThenable(result)) { + return result.then(() => { + --actDepth; + }); + } + --actDepth; + return Promise.resolve(); + } + const previousRequestAnimationFrame = options.requestAnimationFrame; const rerender = setupRerender(); @@ -36,14 +62,19 @@ export function act(cb) { teardown(); options.requestAnimationFrame = previousRequestAnimationFrame; + + --actDepth; }; const result = cb(); - if (result != null && typeof result.then === 'function') { + if (isThenable(result)) { return result.then(finish); } + // nb. If the callback is synchronous, effects must be flushed before + // `act` returns, so that the caller does not have to await the result, + // even though React recommends this. finish(); return Promise.resolve(); } diff --git a/test-utils/test/shared/act.test.js b/test-utils/test/shared/act.test.js index bf9c58a0a6..1b4b47f1b2 100644 --- a/test-utils/test/shared/act.test.js +++ b/test-utils/test/shared/act.test.js @@ -1,5 +1,5 @@ import { options, createElement as h, render } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; +import { useEffect, useReducer, useState } from 'preact/hooks'; import { setupScratch, teardown } from '../../../test/_util/helpers'; import { act } from '../../src'; @@ -238,4 +238,97 @@ describe('act', () => { 'act result resolved' ]); }); + + context('when `act` calls are nested', () => { + it('should invoke nested sync callback and return a Promise', () => { + let innerResult; + const spy = sinon.stub(); + + act(() => { + innerResult = act(spy); + }); + + expect(spy).to.be.calledOnce; + expect(innerResult.then).to.be.a('function'); + }); + + it('should invoke nested async callback and return a Promise', async () => { + const events = []; + + await act(async () => { + events.push('began outer act callback'); + await act(async () => { + events.push('began inner act callback'); + await Promise.resolve(); + events.push('end inner act callback'); + }); + events.push('end outer act callback'); + }); + events.push('act finished'); + + expect(events).to.deep.equal([ + 'began outer act callback', + 'began inner act callback', + 'end inner act callback', + 'end outer act callback', + 'act finished' + ]); + }); + + it('should only flush effects when outer `act` call returns', () => { + let counter = 0; + + function Widget() { + useEffect(() => { + ++counter; + }); + const [, forceUpdate] = useReducer(x => x + 1, 0); + return ; + } + + act(() => { + render(, scratch); + const button = scratch.querySelector('button'); + expect(counter).to.equal(0); + + act(() => { + button.dispatchEvent(new Event('click')); + }); + + // Effect triggered by inner `act` call should not have been + // flushed yet. + expect(counter).to.equal(0); + }); + + // Effects triggered by inner `act` call should now have been + // flushed. + expect(counter).to.equal(2); + }); + + it('should only flush updates when outer `act` call returns', () => { + function Button() { + const [count, setCount] = useState(0); + const increment = () => setCount(count => count + 1); + return ; + } + + render(