diff --git a/cleanup-after-each.js b/cleanup-after-each.js index 8f2e439..747162a 100644 --- a/cleanup-after-each.js +++ b/cleanup-after-each.js @@ -1 +1,3 @@ -afterEach(require('./dist').cleanup); +afterEach(() => { + return require('./dist/cleanup-async')(); +}); diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js new file mode 100644 index 0000000..5b8f339 --- /dev/null +++ b/src/__tests__/new-act.js @@ -0,0 +1,76 @@ +let asyncAct; + +jest.mock('react-test-renderer', () => ({ + act: cb => { + return cb(); + }, +})); + +beforeEach(() => { + jest.resetModules(); + asyncAct = require('../act-compat').asyncAct; + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + console.error.mockRestore(); +}); + +test('async act works when it does not exist (older versions of react)', async () => { + const callback = jest.fn(); + await asyncAct(async () => { + await Promise.resolve(); + await callback(); + }); + expect(console.error).toHaveBeenCalledTimes(0); + expect(callback).toHaveBeenCalledTimes(1); + + callback.mockClear(); + console.error.mockClear(); + + await asyncAct(async () => { + await Promise.resolve(); + await callback(); + }); + expect(console.error).toHaveBeenCalledTimes(0); + expect(callback).toHaveBeenCalledTimes(1); +}); + +test('async act recovers from errors', async () => { + try { + await asyncAct(async () => { + await null; + throw new Error('test error'); + }); + } catch (err) { + console.error('call console.error'); + } + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "call console.error", + ], + ] + `); +}); + +test('async act recovers from sync errors', async () => { + try { + await asyncAct(() => { + throw new Error('test error'); + }); + } catch (err) { + console.error('call console.error'); + } + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "call console.error", + ], + ] + `); +}); + +/* eslint no-console:0 */ diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js index 40fad54..3781189 100644 --- a/src/__tests__/no-act.js +++ b/src/__tests__/no-act.js @@ -1,4 +1,15 @@ -import { act } from '../'; +let act, asyncAct; + +beforeEach(() => { + jest.resetModules(); + act = require('..').act; + asyncAct = require('../act-compat').asyncAct; + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + console.error.mockRestore(); +}); jest.mock('react-test-renderer', () => ({})); @@ -6,4 +17,64 @@ test('act works even when there is no act from test renderer', () => { const callback = jest.fn(); act(callback); expect(callback).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledTimes(0); +}); + +test('async act works when it does not exist (older versions of react)', async () => { + const callback = jest.fn(); + await asyncAct(async () => { + await Promise.resolve(); + await callback(); + }); + expect(console.error).toHaveBeenCalledTimes(0); + expect(callback).toHaveBeenCalledTimes(1); + + callback.mockClear(); + console.error.mockClear(); + + await asyncAct(async () => { + await Promise.resolve(); + await callback(); + }); + expect(console.error).toHaveBeenCalledTimes(0); + expect(callback).toHaveBeenCalledTimes(1); +}); + +test('async act recovers from errors', async () => { + try { + await asyncAct(async () => { + await null; + throw new Error('test error'); + }); + } catch (err) { + console.error('call console.error'); + } + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "call console.error", + ], + ] + `); +}); + +test('async act recovers from sync errors', async () => { + try { + await asyncAct(() => { + throw new Error('test error'); + }); + } catch (err) { + console.error('call console.error'); + } + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "call console.error", + ], + ] + `); }); + +/* eslint no-console:0 */ diff --git a/src/__tests__/old-act.js b/src/__tests__/old-act.js index 86afaea..5414334 100644 --- a/src/__tests__/old-act.js +++ b/src/__tests__/old-act.js @@ -1,25 +1,51 @@ -import { asyncAct } from '../act-compat'; +let asyncAct; + +beforeEach(() => { + jest.resetModules(); + asyncAct = require('../act-compat').asyncAct; + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + console.error.mockRestore(); +}); jest.mock('react-test-renderer', () => ({ act: cb => { - const promise = cb(); + cb(); return { then() { - console.error('blah, do not do this'); - return promise; + console.error( + 'Warning: Do not await the result of calling TestRenderer.act(...), it is not a Promise.', + ); }, }; }, })); test('async act works even when the act is an old one', async () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); const callback = jest.fn(); await asyncAct(async () => { + console.error('sigil'); await Promise.resolve(); await callback(); + console.error('sigil'); }); - expect(console.error.mock.calls).toMatchInlineSnapshot(`Array []`); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Array [ + "sigil", + ], + ], + Array [ + "It looks like you're using a version of react-test-renderer that supports the \\"act\\" function, but not an awaitable version of \\"act\\" which you will need. Please upgrade to at least react-test-renderer@16.9.0 to remove this warning.", + ], + Array [ + "sigil", + ], + ] + `); expect(callback).toHaveBeenCalledTimes(1); // and it doesn't warn you twice @@ -32,6 +58,46 @@ test('async act works even when the act is an old one', async () => { }); expect(console.error).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(1); +}); - console.error.mockRestore(); +test('async act recovers from async errors', async () => { + try { + await asyncAct(async () => { + await null; + throw new Error('test error'); + }); + } catch (err) { + console.error('call console.error'); + } + expect(console.error).toHaveBeenCalledTimes(2); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "It looks like you're using a version of react-test-renderer that supports the \\"act\\" function, but not an awaitable version of \\"act\\" which you will need. Please upgrade to at least react-test-renderer@16.9.0 to remove this warning.", + ], + Array [ + "call console.error", + ], + ] + `); }); + +test('async act recovers from sync errors', async () => { + try { + await asyncAct(() => { + throw new Error('test error'); + }); + } catch (err) { + console.error('call console.error'); + } + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "call console.error", + ], + ] + `); +}); + +/* eslint no-console:0 */ diff --git a/src/act-compat.js b/src/act-compat.js index 25a0531..5de7829 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -1,42 +1,124 @@ -let reactAct; -let actSupported = false; -let asyncActSupported = false; - -try { - reactAct = require('react-test-renderer').act; - actSupported = reactAct !== undefined; - - const originalError = console.error; - let errorCalled = false; - console.error = () => { - errorCalled = true; - }; - console.error.calls = []; - /* istanbul ignore next */ - reactAct(() => ({ then: () => {} })).then(() => {}); - /* istanbul ignore next */ - if (!errorCalled) { - asyncActSupported = true; - } - console.error = originalError; -} catch (error) { - // ignore, this is to support old versions of react -} +import React from 'react'; +import * as testUtils from 'react-test-renderer'; + +const reactAct = testUtils.act; +const actSupported = reactAct !== undefined; -function actPolyfill(callback) { - callback(); +function actPolyfill(cb) { + cb(); } const act = reactAct || actPolyfill; -async function asyncActPolyfill(cb) { - await cb(); - // make all effects resolve after - act(() => {}); -} +let youHaveBeenWarned = false; +let isAsyncActSupported = null; + +function asyncAct(cb) { + if (actSupported === true) { + if (isAsyncActSupported === null) { + return new Promise((resolve, reject) => { + // patch console.error here + const originalConsoleError = console.error; + console.error = function error(...args) { + /* if console.error fired *with that specific message* */ + /* istanbul ignore next */ + if ( + args[0].indexOf('Warning: Do not await the result of calling TestRenderer.act') === 0 + ) { + // v16.8.6 + isAsyncActSupported = false; + } else if ( + args[0].indexOf( + 'Warning: The callback passed to TestRenderer.act(...) function must not return anything', + ) === 0 + ) { + // no-op + } else { + originalConsoleError.call(console, args); + } + }; + let cbReturn, result; + try { + result = reactAct(() => { + cbReturn = cb(); + return cbReturn; + }); + } catch (err) { + console.error = originalConsoleError; + reject(err); + return; + } + + result.then( + () => { + console.error = originalConsoleError; + // if it got here, it means async act is supported + isAsyncActSupported = true; + resolve(); + }, + err => { + console.error = originalConsoleError; + isAsyncActSupported = true; + reject(err); + }, + ); -// istanbul ignore next -const asyncAct = asyncActSupported ? reactAct : asyncActPolyfill; + // 16.8.6's act().then() doesn't call a resolve handler, so we need to manually flush here, sigh + + if (isAsyncActSupported === false) { + console.error = originalConsoleError; + /* istanbul-ignore-next */ + if (!youHaveBeenWarned) { + // if act is supported and async act isn't and they're trying to use async + // act, then they need to upgrade from 16.8 to 16.9. + // This is a seemless upgrade, so we'll add a warning + console.error( + `It looks like you're using a version of react-test-renderer that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-test-renderer@16.9.0 to remove this warning.`, + ); + youHaveBeenWarned = true; + } + + cbReturn.then(() => { + // a faux-version. + // todo - copy https://github.com/facebook/react/blob/master/packages/shared/enqueueTask.js + Promise.resolve().then(() => { + // use sync act to flush effects + act(() => {}); + resolve(); + }); + }, reject); + } + }); + } else if (isAsyncActSupported === false) { + // use the polyfill directly + let result; + act(() => { + result = cb(); + }); + return result.then(() => { + return Promise.resolve().then(() => { + // use sync act to flush effects + act(() => {}); + }); + }); + } + // all good! regular act + return act(cb); + } + // use the polyfill + let result; + act(() => { + result = cb(); + }); + return result.then(() => { + return Promise.resolve().then(() => { + // use sync act to flush effects + act(() => {}); + }); + }); +} export default act; export { asyncAct }; + +/* eslint no-console:0 */ diff --git a/src/cleanup-async.js b/src/cleanup-async.js new file mode 100644 index 0000000..6ad8d9e --- /dev/null +++ b/src/cleanup-async.js @@ -0,0 +1,10 @@ +// This file is for use by the top-level export +// @testing-library/react/cleanup-after-each +// It is not meant to be used directly + +module.exports = async function cleanupAsync() { + const { asyncAct } = require('./act-compat'); + const { cleanup } = require('./index'); + await asyncAct(async () => {}); + cleanup(); +};