diff --git a/jest.config.js b/jest.config.js index 5c840226..0ed33704 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,8 +8,8 @@ module.exports = Object.assign(jestConfig, { // minimum coverage of jobs using React 17 and 18 branches: 80, functions: 78, - lines: 84, - statements: 84, + lines: 79, + statements: 79, }, }, }) diff --git a/src/__tests__/act.js b/src/__tests__/act.js index b60aac37..5430f28b 100644 --- a/src/__tests__/act.js +++ b/src/__tests__/act.js @@ -1,5 +1,5 @@ import * as React from 'react' -import {render, fireEvent, screen} from '../' +import {act, render, fireEvent, screen} from '../' test('render calls useEffect immediately', () => { const effectCb = jest.fn() @@ -43,3 +43,27 @@ test('calls to hydrate will run useEffects', () => { render(, {hydrate: true}) expect(effectCb).toHaveBeenCalledTimes(1) }) + +test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', () => { + global.IS_REACT_ACT_ENVIRONMENT = false + + expect(() => + act(() => { + throw new Error('threw') + }), + ).toThrow('threw') + + expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false) +}) + +test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => { + global.IS_REACT_ACT_ENVIRONMENT = false + + await expect(() => + act(async () => { + throw new Error('thenable threw') + }), + ).rejects.toThrow('thenable threw') + + expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false) +}) diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index 787a944d..cf222aec 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -17,8 +17,6 @@ function ComponentWithLoader() { let cancelled = false fetchAMessage().then(data => { if (!cancelled) { - // Will trigger "missing act" warnings in React 18 with real timers - // Need to wait for an action on https://github.com/reactwg/react-18/discussions/23#discussioncomment-1087897 setState({data, loading: false}) } }) diff --git a/src/act-compat.js b/src/act-compat.js index 16124afc..c8889e65 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -15,7 +15,79 @@ function actPolyfill(cb) { ReactDOM.render(
, document.createElement('div')) } -const act = isomorphicAct || domAct || actPolyfill +function getGlobalThis() { + /* istanbul ignore else */ + if (typeof self !== 'undefined') { + return self + } + /* istanbul ignore next */ + if (typeof window !== 'undefined') { + return window + } + /* istanbul ignore next */ + if (typeof global !== 'undefined') { + return global + } + /* istanbul ignore next */ + throw new Error('unable to locate global object') +} + +function setReactActEnvironment(isReactActEnvironment) { + getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment +} + +function getIsReactActEnvironment() { + return getGlobalThis().IS_REACT_ACT_ENVIRONMENT +} + +function withGlobalActEnvironment(actImplementation) { + return callback => { + const previousActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(true) + try { + // The return value of `act` is always a thenable. + let callbackNeedsToBeAwaited = false + const actResult = actImplementation(() => { + const result = callback() + if ( + result !== null && + typeof result === 'object' && + typeof result.then === 'function' + ) { + callbackNeedsToBeAwaited = true + } + return result + }) + if (callbackNeedsToBeAwaited) { + const thenable = actResult + return { + then: (resolve, reject) => { + thenable.then( + returnValue => { + setReactActEnvironment(previousActEnvironment) + resolve(returnValue) + }, + error => { + setReactActEnvironment(previousActEnvironment) + reject(error) + }, + ) + }, + } + } else { + setReactActEnvironment(previousActEnvironment) + return actResult + } + } catch (error) { + // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT + // or if we have to await the callback first. + setReactActEnvironment(previousActEnvironment) + throw error + } + } +} + +const act = withGlobalActEnvironment(isomorphicAct || domAct || actPolyfill) let youHaveBeenWarned = false let isAsyncActSupported = null @@ -131,6 +203,6 @@ function asyncAct(cb) { } export default act -export {asyncAct} +export {asyncAct, setReactActEnvironment, getIsReactActEnvironment} /* eslint no-console:0 */ diff --git a/src/pure.js b/src/pure.js index 0ac63feb..dc5fa3fa 100644 --- a/src/pure.js +++ b/src/pure.js @@ -5,7 +5,11 @@ import { prettyDOM, configure as configureDTL, } from '@testing-library/dom' -import act, {asyncAct} from './act-compat' +import act, { + asyncAct, + getIsReactActEnvironment, + setReactActEnvironment, +} from './act-compat' import {fireEvent} from './fire-event' configureDTL({ @@ -30,7 +34,18 @@ if (React.startTransition !== undefined) { unstable_advanceTimersWrapper: cb => { return act(cb) }, - asyncWrapper: cb => cb(), + // We just want to run `waitFor` without IS_REACT_ACT_ENVIRONMENT + // But that's not necessarily how `asyncWrapper` is used since it's a public method. + // Let's just hope nobody else is using it. + asyncWrapper: async cb => { + const previousActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(false) + try { + return await cb() + } finally { + setReactActEnvironment(previousActEnvironment) + } + }, }) } diff --git a/tests/setup-env.js b/tests/setup-env.js index 264828a9..6a5fcbee 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1 +1,9 @@ import '@testing-library/jest-dom/extend-expect' + +beforeEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = false +})