From c888cb6e7afab8719ba385838a9323237357d514 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Mon, 13 Sep 2021 10:49:05 +0200 Subject: [PATCH 01/10] feat: Use concurrent React when available (#937) BREAKING CHANGE: If you have React 18 installed, we'll use the new [`createRoot` API](https://github.com/reactwg/react-18/discussions/5) by default which comes with a set of [changes while also enabling support for concurrent features](https://github.com/reactwg/react-18/discussions/4). To can opt-out of this change by using `render(ui, { legacyRoot: true } )`. But be aware that the legacy root API is deprecated in React 18 and its usage will trigger console warnings. --- .github/workflows/validate.yml | 3 + jest.config.js | 15 +++ package.json | 2 +- src/__tests__/cleanup.js | 19 +--- src/__tests__/end-to-end.js | 2 + src/__tests__/new-act.js | 6 +- src/__tests__/no-act.js | 8 ++ src/__tests__/old-act.js | 6 +- src/__tests__/render.js | 33 +++++++ src/__tests__/stopwatch.js | 5 +- src/act-compat.js | 9 +- src/pure.js | 161 ++++++++++++++++++++++++++------- tests/setup-env.js | 19 ---- types/index.d.ts | 5 + 14 files changed, 210 insertions(+), 83 deletions(-) create mode 100644 jest.config.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 67b71c24..45cc7d13 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,6 +16,7 @@ jobs: # ignore all-contributors PRs if: ${{ !contains(github.head_ref, 'all-contributors') }} strategy: + fail-fast: false matrix: # TODO: relax `'16.9.1'` to `16` once GitHub has 16.9.1 cached. 16.9.0 is broken due to https://github.com/nodejs/node/issues/40030 node: [12, 14, '16.9.1'] @@ -52,6 +53,8 @@ jobs: - name: ⬆️ Upload coverage report uses: codecov/codecov-action@v1 + with: + flags: ${{ matrix.react }} release: needs: main diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..5c840226 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,15 @@ +const {jest: jestConfig} = require('kcd-scripts/config') + +module.exports = Object.assign(jestConfig, { + coverageThreshold: { + ...jestConfig.coverageThreshold, + // full coverage across the build matrix (React 17, 18) but not in a single job + './src/pure': { + // minimum coverage of jobs using React 17 and 18 + branches: 80, + functions: 78, + lines: 84, + statements: 84, + }, + }, +}) diff --git a/package.json b/package.json index 1cd283fb..cbf61367 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0" + "@testing-library/dom": "^8.5.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js index 9d3f52d4..0dcbac12 100644 --- a/src/__tests__/cleanup.js +++ b/src/__tests__/cleanup.js @@ -83,10 +83,7 @@ describe('fake timers and missing act warnings', () => { expect(microTaskSpy).toHaveBeenCalledTimes(0) // console.error is mocked // eslint-disable-next-line no-console - expect(console.error).toHaveBeenCalledTimes( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 1 : 0, - ) + expect(console.error).toHaveBeenCalledTimes(0) }) test('cleanup does not swallow missing act warnings', () => { @@ -118,16 +115,10 @@ describe('fake timers and missing act warnings', () => { expect(deferredStateUpdateSpy).toHaveBeenCalledTimes(1) // console.error is mocked // eslint-disable-next-line no-console - expect(console.error).toHaveBeenCalledTimes( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 2 : 1, - ) + expect(console.error).toHaveBeenCalledTimes(1) // eslint-disable-next-line no-console - expect( - console.error.mock.calls[ - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 1 : 0 - ][0], - ).toMatch('a test was not wrapped in act(...)') + expect(console.error.mock.calls[0][0]).toMatch( + 'a test was not wrapped in act(...)', + ) }) }) diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index cf222aec..787a944d 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -17,6 +17,8 @@ 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/__tests__/new-act.js b/src/__tests__/new-act.js index af81e29c..42552594 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -1,4 +1,4 @@ -let asyncAct, consoleErrorMock +let asyncAct jest.mock('react-dom/test-utils', () => ({ act: cb => { @@ -9,11 +9,11 @@ jest.mock('react-dom/test-utils', () => ({ beforeEach(() => { jest.resetModules() asyncAct = require('../act-compat').asyncAct - consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}) + jest.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { - consoleErrorMock.mockRestore() + console.error.mockRestore() }) test('async act works when it does not exist (older versions of react)', async () => { diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js index d739e763..de4117bb 100644 --- a/src/__tests__/no-act.js +++ b/src/__tests__/no-act.js @@ -12,7 +12,15 @@ afterEach(() => { consoleErrorMock.mockRestore() }) +// no react-dom/test-utils also means no isomorphic act since isomorphic act got released after test-utils act jest.mock('react-dom/test-utils', () => ({})) +jest.mock('react', () => { + const ReactActual = jest.requireActual('react') + + delete ReactActual.unstable_act + + return ReactActual +}) test('act works even when there is no act from test utils', () => { const callback = jest.fn() diff --git a/src/__tests__/old-act.js b/src/__tests__/old-act.js index 6081fef8..0153fea3 100644 --- a/src/__tests__/old-act.js +++ b/src/__tests__/old-act.js @@ -1,13 +1,13 @@ -let asyncAct, consoleErrorMock +let asyncAct beforeEach(() => { jest.resetModules() asyncAct = require('../act-compat').asyncAct - consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}) + jest.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { - consoleErrorMock.mockRestore() + console.error.mockRestore() }) jest.mock('react-dom/test-utils', () => ({ diff --git a/src/__tests__/render.js b/src/__tests__/render.js index fea1a649..ac996444 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -101,3 +101,36 @@ test('flushes useEffect cleanup functions sync on unmount()', () => { expect(spy).toHaveBeenCalledTimes(1) }) + +test('throws if `legacyRoot: false` is used with an incomaptible version', () => { + const isConcurrentReact = typeof ReactDOM.createRoot === 'function' + + const performConcurrentRender = () => render(
, {legacyRoot: false}) + + // eslint-disable-next-line jest/no-if -- jest doesn't support conditional tests + if (isConcurrentReact) { + // eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests + expect(performConcurrentRender).not.toThrow() + } else { + // eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests + expect(performConcurrentRender).toThrowError( + `Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).`, + ) + } +}) + +test('can be called multiple times on the same container', () => { + const container = document.createElement('div') + + const {unmount} = render(, {container}) + + expect(container).toContainHTML('') + + render(, {container}) + + expect(container).toContainHTML('') + + unmount() + + expect(container).toBeEmptyDOMElement() +}) diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js index 400fce10..eeaf395c 100644 --- a/src/__tests__/stopwatch.js +++ b/src/__tests__/stopwatch.js @@ -53,8 +53,5 @@ test('unmounts a component', async () => { // and get an error. await sleep(5) // eslint-disable-next-line no-console - expect(console.error).toHaveBeenCalledTimes( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 1 : 0, - ) + expect(console.error).not.toHaveBeenCalled() }) diff --git a/src/act-compat.js b/src/act-compat.js index 40ecdab9..16124afc 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -2,8 +2,9 @@ import * as React from 'react' import ReactDOM from 'react-dom' import * as testUtils from 'react-dom/test-utils' -const reactAct = testUtils.act -const actSupported = reactAct !== undefined +const isomorphicAct = React.unstable_act +const domAct = testUtils.act +const actSupported = domAct !== undefined // act is supported react-dom@16.8.0 // so for versions that don't have act from test utils @@ -14,7 +15,7 @@ function actPolyfill(cb) { ReactDOM.render(
, document.createElement('div')) } -const act = reactAct || actPolyfill +const act = isomorphicAct || domAct || actPolyfill let youHaveBeenWarned = false let isAsyncActSupported = null @@ -50,7 +51,7 @@ function asyncAct(cb) { } let cbReturn, result try { - result = reactAct(() => { + result = domAct(() => { cbReturn = cb() return cbReturn }) diff --git a/src/pure.js b/src/pure.js index 75098f78..0ac63feb 100644 --- a/src/pure.js +++ b/src/pure.js @@ -25,32 +25,73 @@ configureDTL({ }, }) +if (React.startTransition !== undefined) { + configureDTL({ + unstable_advanceTimersWrapper: cb => { + return act(cb) + }, + asyncWrapper: cb => cb(), + }) +} + +// Ideally we'd just use a WeakMap where containers are keys and roots are values. +// We use two variables so that we can bail out in constant time when we render with a new container (most common use case) +/** + * @type {Set} + */ const mountedContainers = new Set() +/** + * @type Array<{container: import('react-dom').Container, root: ReturnType}> + */ +const mountedRootEntries = [] -function render( - ui, - { - container, - baseElement = container, - queries, - hydrate = false, - wrapper: WrapperComponent, - } = {}, -) { - if (!baseElement) { - // default to document.body instead of documentElement to avoid output of potentially-large - // head elements (such as JSS style blocks) in debug output - baseElement = document.body +function createConcurrentRoot(container, options) { + if (typeof ReactDOM.createRoot !== 'function') { + throw new TypeError( + `Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).'`, + ) } - if (!container) { - container = baseElement.appendChild(document.createElement('div')) + const root = options.hydrate + ? ReactDOM.hydrateRoot(container) + : ReactDOM.createRoot(container) + + return { + hydrate(element) { + /* istanbul ignore if */ + if (!options.hydrate) { + throw new Error( + 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.', + ) + } + root.render(element) + }, + render(element) { + root.render(element) + }, + unmount() { + root.unmount() + }, } +} - // we'll add it to the mounted containers regardless of whether it's actually - // added to document.body so the cleanup method works regardless of whether - // they're passing us a custom container or not. - mountedContainers.add(container) +function createLegacyRoot(container) { + return { + hydrate(element) { + ReactDOM.hydrate(element, container) + }, + render(element) { + ReactDOM.render(element, container) + }, + unmount() { + ReactDOM.unmountComponentAtNode(container) + }, + } +} +function renderRoot( + ui, + {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, +) { const wrapUiIfNeeded = innerElement => WrapperComponent ? React.createElement(WrapperComponent, null, innerElement) @@ -58,9 +99,9 @@ function render( act(() => { if (hydrate) { - ReactDOM.hydrate(wrapUiIfNeeded(ui), container) + root.hydrate(wrapUiIfNeeded(ui), container) } else { - ReactDOM.render(wrapUiIfNeeded(ui), container) + root.render(wrapUiIfNeeded(ui), container) } }) @@ -75,11 +116,15 @@ function render( console.log(prettyDOM(el, maxLength, options)), unmount: () => { act(() => { - ReactDOM.unmountComponentAtNode(container) + root.unmount() }) }, rerender: rerenderUi => { - render(wrapUiIfNeeded(rerenderUi), {container, baseElement}) + renderRoot(wrapUiIfNeeded(rerenderUi), { + container, + baseElement, + root, + }) // Intentionally do not return anything to avoid unnecessarily complicating the API. // folks can use all the same utilities we return in the first place that are bound to the container }, @@ -99,20 +144,66 @@ function render( } } -function cleanup() { - mountedContainers.forEach(cleanupAtContainer) +function render( + ui, + { + container, + baseElement = container, + legacyRoot = typeof ReactDOM.createRoot !== 'function', + queries, + hydrate = false, + wrapper, + } = {}, +) { + if (!baseElement) { + // default to document.body instead of documentElement to avoid output of potentially-large + // head elements (such as JSS style blocks) in debug output + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + let root + // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first. + if (!mountedContainers.has(container)) { + const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot + root = createRootImpl(container, {hydrate}) + + mountedRootEntries.push({container, root}) + // we'll add it to the mounted containers regardless of whether it's actually + // added to document.body so the cleanup method works regardless of whether + // they're passing us a custom container or not. + mountedContainers.add(container) + } else { + mountedRootEntries.forEach(rootEntry => { + if (rootEntry.container === container) { + root = rootEntry.root + } + }) + } + + return renderRoot(ui, { + container, + baseElement, + queries, + hydrate, + wrapper, + root, + }) } -// maybe one day we'll expose this (perhaps even as a utility returned by render). -// but let's wait until someone asks for it. -function cleanupAtContainer(container) { - act(() => { - ReactDOM.unmountComponentAtNode(container) +function cleanup() { + mountedRootEntries.forEach(({root, container}) => { + act(() => { + root.unmount() + }) + if (container.parentNode === document.body) { + document.body.removeChild(container) + } }) - if (container.parentNode === document.body) { - document.body.removeChild(container) - } - mountedContainers.delete(container) + mountedRootEntries.length = 0 + mountedContainers.clear() } // just re-export everything from dom-testing-library diff --git a/tests/setup-env.js b/tests/setup-env.js index 6c0b953b..264828a9 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,20 +1 @@ import '@testing-library/jest-dom/extend-expect' - -let consoleErrorMock - -beforeEach(() => { - const originalConsoleError = console.error - consoleErrorMock = jest - .spyOn(console, 'error') - .mockImplementation((message, ...optionalParams) => { - // Ignore ReactDOM.render/ReactDOM.hydrate deprecation warning - if (message.indexOf('Use createRoot instead.') !== -1) { - return - } - originalConsoleError(message, ...optionalParams) - }) -}) - -afterEach(() => { - consoleErrorMock.mockRestore() -}) diff --git a/types/index.d.ts b/types/index.d.ts index 663e6280..b4386996 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -58,6 +58,11 @@ export interface RenderOptions< * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) */ hydrate?: boolean + /** + * Set to `true` if you want to force synchronous `ReactDOM.render`. + * Otherwise `render` will default to concurrent React if available. + */ + legacyRoot?: boolean /** * Queries to bind. Overrides the default set from DOM Testing Library unless merged. * From a045bc30848bf780a5fa1bcaf50196581ea87d02 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Thu, 28 Oct 2021 10:45:19 +0200 Subject: [PATCH 02/10] fix: Don't trigger "missing act" warnings when using `waitFor`+real timers (#980) --- jest.config.js | 4 +- src/__tests__/act.js | 26 ++++++++++++- src/__tests__/end-to-end.js | 2 - src/act-compat.js | 76 ++++++++++++++++++++++++++++++++++++- src/pure.js | 19 +++++++++- tests/setup-env.js | 8 ++++ 6 files changed, 126 insertions(+), 9 deletions(-) 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 +}) From 162b72f48896b3ba6e91ec73b4067e593e93daa6 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Thu, 28 Oct 2021 12:45:19 +0200 Subject: [PATCH 03/10] chore: set protocol for npm to https (#987) Co-authored-by: Philipp Fritsche --- .npmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.npmrc b/.npmrc index d2722898..1df2a6d8 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -registry=http://registry.npmjs.org/ +registry=https://registry.npmjs.org/ package-lock=false From 68d2a23d70121b3c7eac3e2a4e5358e5a5be59f0 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Thu, 28 Oct 2021 12:51:48 +0200 Subject: [PATCH 04/10] fix: Empty commit to force semantic-release From d8c6b4dbb54a36b43820ce8c1c13b0c1a334ad46 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Sun, 31 Oct 2021 09:52:45 +0100 Subject: [PATCH 05/10] fix(render): Actually hydrate with given ui (#988) --- jest.config.js | 6 +++--- src/__tests__/render.js | 36 +++++++++++++++++++++++++++++++++++- src/pure.js | 27 +++++++++++++++++++-------- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/jest.config.js b/jest.config.js index 0ed33704..f30f76e9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,10 +6,10 @@ module.exports = Object.assign(jestConfig, { // full coverage across the build matrix (React 17, 18) but not in a single job './src/pure': { // minimum coverage of jobs using React 17 and 18 - branches: 80, + branches: 75, functions: 78, - lines: 79, - statements: 79, + lines: 76, + statements: 76, }, }, }) diff --git a/src/__tests__/render.js b/src/__tests__/render.js index ac996444..d5f78b57 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -1,6 +1,13 @@ import * as React from 'react' import ReactDOM from 'react-dom' -import {render, screen} from '../' +import ReactDOMServer from 'react-dom/server' +import {fireEvent, render, screen} from '../' + +afterEach(() => { + if (console.error.mockRestore !== undefined) { + console.error.mockRestore() + } +}) test('renders div into document', () => { const ref = React.createRef() @@ -134,3 +141,30 @@ test('can be called multiple times on the same container', () => { expect(container).toBeEmptyDOMElement() }) + +test('hydrate will make the UI interactive', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + function App() { + const [clicked, handleClick] = React.useReducer(n => n + 1, 0) + + return ( + + ) + } + const ui = + const container = document.createElement('div') + document.body.appendChild(container) + container.innerHTML = ReactDOMServer.renderToString(ui) + + expect(container).toHaveTextContent('clicked:0') + + render(ui, {container, hydrate: true}) + + expect(console.error).not.toHaveBeenCalled() + + fireEvent.click(container.querySelector('button')) + + expect(container).toHaveTextContent('clicked:1') +}) diff --git a/src/pure.js b/src/pure.js index dc5fa3fa..309e2090 100644 --- a/src/pure.js +++ b/src/pure.js @@ -60,25 +60,36 @@ const mountedContainers = new Set() */ const mountedRootEntries = [] -function createConcurrentRoot(container, options) { +function createConcurrentRoot( + container, + {hydrate, ui, wrapper: WrapperComponent}, +) { if (typeof ReactDOM.createRoot !== 'function') { throw new TypeError( `Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).'`, ) } - const root = options.hydrate - ? ReactDOM.hydrateRoot(container) - : ReactDOM.createRoot(container) + let root + if (hydrate) { + act(() => { + root = ReactDOM.hydrateRoot( + container, + WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, + ) + }) + } else { + root = ReactDOM.createRoot(container) + } return { - hydrate(element) { + hydrate() { /* istanbul ignore if */ - if (!options.hydrate) { + if (!hydrate) { throw new Error( 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.', ) } - root.render(element) + // Nothing to do since hydration happens when creating the root object. }, render(element) { root.render(element) @@ -183,7 +194,7 @@ function render( // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first. if (!mountedContainers.has(container)) { const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot - root = createRootImpl(container, {hydrate}) + root = createRootImpl(container, {hydrate, ui, wrapper}) mountedRootEntries.push({container, root}) // we'll add it to the mounted containers regardless of whether it's actually From 8e536f8cd6fedf35690f62d1c0d055faef14642e Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Tue, 23 Nov 2021 15:15:34 +0000 Subject: [PATCH 06/10] feat: Enable "missing act" warnings in React 18 by default (#994) --- src/act-compat.js | 18 +++++++++++------- src/index.js | 16 ++++++++++++++++ tests/setup-env.js | 8 -------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/act-compat.js b/src/act-compat.js index c8889e65..ea20e25e 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -32,7 +32,7 @@ function getGlobalThis() { throw new Error('unable to locate global object') } -function setReactActEnvironment(isReactActEnvironment) { +function setIsReactActEnvironment(isReactActEnvironment) { getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment } @@ -43,7 +43,7 @@ function getIsReactActEnvironment() { function withGlobalActEnvironment(actImplementation) { return callback => { const previousActEnvironment = getIsReactActEnvironment() - setReactActEnvironment(true) + setIsReactActEnvironment(true) try { // The return value of `act` is always a thenable. let callbackNeedsToBeAwaited = false @@ -64,24 +64,24 @@ function withGlobalActEnvironment(actImplementation) { then: (resolve, reject) => { thenable.then( returnValue => { - setReactActEnvironment(previousActEnvironment) + setIsReactActEnvironment(previousActEnvironment) resolve(returnValue) }, error => { - setReactActEnvironment(previousActEnvironment) + setIsReactActEnvironment(previousActEnvironment) reject(error) }, ) }, } } else { - setReactActEnvironment(previousActEnvironment) + setIsReactActEnvironment(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) + setIsReactActEnvironment(previousActEnvironment) throw error } } @@ -203,6 +203,10 @@ function asyncAct(cb) { } export default act -export {asyncAct, setReactActEnvironment, getIsReactActEnvironment} +export { + asyncAct, + setIsReactActEnvironment as setReactActEnvironment, + getIsReactActEnvironment, +} /* eslint no-console:0 */ diff --git a/src/index.js b/src/index.js index 96fbe155..bb0d0270 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat' import {cleanup} from './pure' // if we're running in a test runner that supports afterEach @@ -20,6 +21,21 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { cleanup() }) } + + // No test setup with other test runners available + /* istanbul ignore else */ + if (typeof beforeAll === 'function' && typeof afterAll === 'function') { + // This matches the behavior of React < 18. + let previousIsReactActEnvironment = getIsReactActEnvironment() + beforeAll(() => { + previousIsReactActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(true) + }) + + afterAll(() => { + setReactActEnvironment(previousIsReactActEnvironment) + }) + } } export * from './pure' diff --git a/tests/setup-env.js b/tests/setup-env.js index 6a5fcbee..264828a9 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,9 +1 @@ import '@testing-library/jest-dom/extend-expect' - -beforeEach(() => { - global.IS_REACT_ACT_ENVIRONMENT = true -}) - -afterEach(() => { - global.IS_REACT_ACT_ENVIRONMENT = false -}) From 33cc72c03899d251100dab985559b7237af2b174 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Tue, 1 Mar 2022 19:32:08 +0100 Subject: [PATCH 07/10] chore: Drop usage of isomorphic act (#1019) --- src/act-compat.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/act-compat.js b/src/act-compat.js index ea20e25e..17e5b05c 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -2,7 +2,6 @@ import * as React from 'react' import ReactDOM from 'react-dom' import * as testUtils from 'react-dom/test-utils' -const isomorphicAct = React.unstable_act const domAct = testUtils.act const actSupported = domAct !== undefined @@ -87,7 +86,7 @@ function withGlobalActEnvironment(actImplementation) { } } -const act = withGlobalActEnvironment(isomorphicAct || domAct || actPolyfill) +const act = withGlobalActEnvironment(domAct || actPolyfill) let youHaveBeenWarned = false let isAsyncActSupported = null From 1ba40c913afa7827fc711f4af7b1b06f4a900870 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Tue, 1 Mar 2022 20:20:14 +0100 Subject: [PATCH 08/10] chore: Fix failing codesandbox/ci (#1020) --- .codesandbox/ci.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index f866879a..bf3237bb 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,4 +1,5 @@ { + "installCommand": "install:csb", "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], "node": "12" } diff --git a/package.json b/package.json index cbf61367..55b31ec0 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:bundle:pure": "dotenv -e .bundle.main.env -e .bundle.pure.env kcd-scripts build -- --bundle --no-clean", "build:main": "kcd-scripts build --no-clean", "format": "kcd-scripts format", + "install:csb": "npm install", "lint": "kcd-scripts lint", "setup": "npm install && npm run validate -s", "test": "kcd-scripts test", From ba24a4be96469a5c3e004c186e19c1a57b2fd999 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Fri, 4 Mar 2022 22:06:31 +0100 Subject: [PATCH 09/10] fix: Resolve warning using the wrong react-dom entrypoint (#1018) BREAKING CHANGE: Drop support for React < 18.0.0-rc.1 --- .github/workflows/validate.yml | 5 +- jest.config.js | 15 ---- package.json | 8 +- src/__tests__/new-act.js | 2 +- src/__tests__/no-act.js | 100 ----------------------- src/__tests__/old-act.js | 142 --------------------------------- src/__tests__/render.js | 61 ++++++++++---- src/act-compat.js | 128 +---------------------------- src/pure.js | 58 +++++--------- 9 files changed, 73 insertions(+), 446 deletions(-) delete mode 100644 jest.config.js delete mode 100644 src/__tests__/no-act.js delete mode 100644 src/__tests__/old-act.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 45cc7d13..aafa6cad 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,7 +12,7 @@ on: pull_request: {} jobs: main: - continue-on-error: ${{ matrix.react != 'latest' }} + continue-on-error: ${{ matrix.react != 'current' }} # ignore all-contributors PRs if: ${{ !contains(github.head_ref, 'all-contributors') }} strategy: @@ -20,7 +20,7 @@ jobs: matrix: # TODO: relax `'16.9.1'` to `16` once GitHub has 16.9.1 cached. 16.9.0 is broken due to https://github.com/nodejs/node/issues/40030 node: [12, 14, '16.9.1'] - react: [latest, next, experimental] + react: [current, next, experimental] runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs @@ -47,6 +47,7 @@ jobs: # https://reactjs.org/blog/2019/10/22/react-release-channels.html#using-the-next-channel-for-integration-testing - name: ⚛️ Setup react run: npm install react@${{ matrix.react }} react-dom@${{ matrix.react }} + if: ${{ matrix.react != 'current' }} - name: ▶️ Run validate script run: npm run validate diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index f30f76e9..00000000 --- a/jest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const {jest: jestConfig} = require('kcd-scripts/config') - -module.exports = Object.assign(jestConfig, { - coverageThreshold: { - ...jestConfig.coverageThreshold, - // full coverage across the build matrix (React 17, 18) but not in a single job - './src/pure': { - // minimum coverage of jobs using React 17 and 18 - branches: 75, - functions: 78, - lines: 76, - statements: 76, - }, - }, -}) diff --git a/package.json b/package.json index 55b31ec0..9b9d5ebf 100644 --- a/package.json +++ b/package.json @@ -54,14 +54,14 @@ "dotenv-cli": "^4.0.0", "kcd-scripts": "^11.1.0", "npm-run-all": "^4.1.5", - "react": "^17.0.1", - "react-dom": "^17.0.1", + "react": "18.0.0-rc.1", + "react-dom": "18.0.0-rc.1", "rimraf": "^3.0.2", "typescript": "^4.1.2" }, "peerDependencies": { - "react": "*", - "react-dom": "*" + "react": "18.0.0-rc.1", + "react-dom": "18.0.0-rc.1" }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 42552594..05f9d45a 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -8,7 +8,7 @@ jest.mock('react-dom/test-utils', () => ({ beforeEach(() => { jest.resetModules() - asyncAct = require('../act-compat').asyncAct + asyncAct = require('../act-compat').default jest.spyOn(console, 'error').mockImplementation(() => {}) }) diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js deleted file mode 100644 index de4117bb..00000000 --- a/src/__tests__/no-act.js +++ /dev/null @@ -1,100 +0,0 @@ -let act, asyncAct, React, consoleErrorMock - -beforeEach(() => { - jest.resetModules() - act = require('../pure').act - asyncAct = require('../act-compat').asyncAct - React = require('react') - consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}) -}) - -afterEach(() => { - consoleErrorMock.mockRestore() -}) - -// no react-dom/test-utils also means no isomorphic act since isomorphic act got released after test-utils act -jest.mock('react-dom/test-utils', () => ({})) -jest.mock('react', () => { - const ReactActual = jest.requireActual('react') - - delete ReactActual.unstable_act - - return ReactActual -}) - -test('act works even when there is no act from test utils', () => { - const callback = jest.fn() - act(callback) - expect(callback).toHaveBeenCalledTimes(1) - expect(console.error).toHaveBeenCalledTimes( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 1 : 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( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 2 : 0, - ) - expect(callback).toHaveBeenCalledTimes(1) - - callback.mockClear() - console.error.mockClear() - - await asyncAct(async () => { - await Promise.resolve() - await callback() - }) - expect(console.error).toHaveBeenCalledTimes( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 2 : 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( - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 2 : 1, - ) - expect( - console.error.mock.calls[ - // ReactDOM.render is deprecated in React 18 - React.version.startsWith('18') ? 1 : 0 - ][0], - ).toMatch('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 deleted file mode 100644 index 0153fea3..00000000 --- a/src/__tests__/old-act.js +++ /dev/null @@ -1,142 +0,0 @@ -let asyncAct - -beforeEach(() => { - jest.resetModules() - asyncAct = require('../act-compat').asyncAct - jest.spyOn(console, 'error').mockImplementation(() => {}) -}) - -afterEach(() => { - console.error.mockRestore() -}) - -jest.mock('react-dom/test-utils', () => ({ - act: cb => { - cb() - return { - then() { - console.error( - 'Warning: Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.', - ) - }, - } - }, -})) - -test('async act works even when the act is an old one', async () => { - 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 [ - Array [ - sigil, - ], - Array [ - It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@16.9.0 to remove this warning., - ], - Array [ - sigil, - ], - ] - `) - expect(callback).toHaveBeenCalledTimes(1) - - // and it doesn't warn you twice - 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 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-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@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, - ], - ] - `) -}) - -test('async act can handle any sort of console.error', async () => { - await asyncAct(async () => { - console.error({error: 'some error'}) - await null - }) - - expect(console.error).toHaveBeenCalledTimes(2) - expect(console.error.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - error: some error, - }, - ], - Array [ - It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@16.9.0 to remove this warning., - ], - ] - `) -}) - -test('async act should not show an error when ReactTestUtils.act returns something', async () => { - jest.resetModules() - jest.mock('react-dom/test-utils', () => ({ - act: () => { - return new Promise(resolve => { - console.error( - 'Warning: The callback passed to ReactTestUtils.act(...) function must not return anything', - ) - resolve() - }) - }, - })) - asyncAct = require('../act-compat').asyncAct - await asyncAct(async () => { - await null - }) - - expect(console.error).toHaveBeenCalledTimes(0) -}) - -/* eslint no-console:0 */ diff --git a/src/__tests__/render.js b/src/__tests__/render.js index d5f78b57..88e2b98d 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -109,23 +109,6 @@ test('flushes useEffect cleanup functions sync on unmount()', () => { expect(spy).toHaveBeenCalledTimes(1) }) -test('throws if `legacyRoot: false` is used with an incomaptible version', () => { - const isConcurrentReact = typeof ReactDOM.createRoot === 'function' - - const performConcurrentRender = () => render(
, {legacyRoot: false}) - - // eslint-disable-next-line jest/no-if -- jest doesn't support conditional tests - if (isConcurrentReact) { - // eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests - expect(performConcurrentRender).not.toThrow() - } else { - // eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests - expect(performConcurrentRender).toThrowError( - `Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).`, - ) - } -}) - test('can be called multiple times on the same container', () => { const container = document.createElement('div') @@ -168,3 +151,47 @@ test('hydrate will make the UI interactive', () => { expect(container).toHaveTextContent('clicked:1') }) + +test('hydrate can have a wrapper', () => { + const wrapperComponentMountEffect = jest.fn() + function WrapperComponent({children}) { + React.useEffect(() => { + wrapperComponentMountEffect() + }) + + return children + } + const ui =
+ const container = document.createElement('div') + document.body.appendChild(container) + container.innerHTML = ReactDOMServer.renderToString(ui) + + render(ui, {container, hydrate: true, wrapper: WrapperComponent}) + + expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1) +}) + +test('legacyRoot uses legacy ReactDOM.render', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + render(
, {legacyRoot: true}) + + expect(console.error).toHaveBeenCalledTimes(1) + expect(console.error).toHaveBeenNthCalledWith( + 1, + "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot", + ) +}) + +test('legacyRoot uses legacy ReactDOM.hydrate', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const ui =
+ const container = document.createElement('div') + container.innerHTML = ReactDOMServer.renderToString(ui) + render(ui, {container, hydrate: true, legacyRoot: true}) + + expect(console.error).toHaveBeenCalledTimes(1) + expect(console.error).toHaveBeenNthCalledWith( + 1, + "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot", + ) +}) diff --git a/src/act-compat.js b/src/act-compat.js index 17e5b05c..d7a09d68 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -1,18 +1,6 @@ -import * as React from 'react' -import ReactDOM from 'react-dom' import * as testUtils from 'react-dom/test-utils' const domAct = testUtils.act -const actSupported = domAct !== undefined - -// act is supported react-dom@16.8.0 -// so for versions that don't have act from test utils -// we do this little polyfill. No warnings, but it's -// better than nothing. -function actPolyfill(cb) { - ReactDOM.unstable_batchedUpdates(cb) - ReactDOM.render(
, document.createElement('div')) -} function getGlobalThis() { /* istanbul ignore else */ @@ -86,124 +74,10 @@ function withGlobalActEnvironment(actImplementation) { } } -const act = withGlobalActEnvironment(domAct || actPolyfill) - -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 */ - const firstArgIsString = typeof args[0] === 'string' - if ( - firstArgIsString && - args[0].indexOf( - 'Warning: Do not await the result of calling ReactTestUtils.act', - ) === 0 - ) { - // v16.8.6 - isAsyncActSupported = false - } else if ( - firstArgIsString && - args[0].indexOf( - 'Warning: The callback passed to ReactTestUtils.act(...) function must not return anything', - ) === 0 - ) { - // no-op - } else { - originalConsoleError.apply(console, args) - } - } - let cbReturn, result - try { - result = domAct(() => { - 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) - }, - ) - - // 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 seamless upgrade, so we'll add a warning - console.error( - `It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@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(() => {}) - }) - }) -} +const act = withGlobalActEnvironment(domAct) export default act export { - asyncAct, setIsReactActEnvironment as setReactActEnvironment, getIsReactActEnvironment, } diff --git a/src/pure.js b/src/pure.js index 309e2090..64b761b0 100644 --- a/src/pure.js +++ b/src/pure.js @@ -1,24 +1,32 @@ import * as React from 'react' import ReactDOM from 'react-dom' +import * as ReactDOMClient from 'react-dom/client' import { getQueriesForElement, prettyDOM, configure as configureDTL, } from '@testing-library/dom' import act, { - asyncAct, getIsReactActEnvironment, setReactActEnvironment, } from './act-compat' import {fireEvent} from './fire-event' configureDTL({ + unstable_advanceTimersWrapper: cb => { + return act(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 => { - let result - await asyncAct(async () => { - result = await cb() - }) - return result + const previousActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(false) + try { + return await cb() + } finally { + setReactActEnvironment(previousActEnvironment) + } }, eventWrapper: cb => { let result @@ -29,26 +37,6 @@ configureDTL({ }, }) -if (React.startTransition !== undefined) { - configureDTL({ - unstable_advanceTimersWrapper: cb => { - return act(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) - } - }, - }) -} - // Ideally we'd just use a WeakMap where containers are keys and roots are values. // We use two variables so that we can bail out in constant time when we render with a new container (most common use case) /** @@ -64,21 +52,16 @@ function createConcurrentRoot( container, {hydrate, ui, wrapper: WrapperComponent}, ) { - if (typeof ReactDOM.createRoot !== 'function') { - throw new TypeError( - `Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).'`, - ) - } let root if (hydrate) { act(() => { - root = ReactDOM.hydrateRoot( + root = ReactDOMClient.hydrateRoot( container, WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, ) }) } else { - root = ReactDOM.createRoot(container) + root = ReactDOMClient.createRoot(container) } return { @@ -175,7 +158,7 @@ function render( { container, baseElement = container, - legacyRoot = typeof ReactDOM.createRoot !== 'function', + legacyRoot = false, queries, hydrate = false, wrapper, @@ -203,6 +186,9 @@ function render( mountedContainers.add(container) } else { mountedRootEntries.forEach(rootEntry => { + // Else is unreachable since `mountedContainers` has the `container`. + // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + /* istanbul ignore else */ if (rootEntry.container === container) { root = rootEntry.root } @@ -236,8 +222,4 @@ function cleanup() { export * from '@testing-library/dom' export {render, cleanup, act, fireEvent} -// NOTE: we're not going to export asyncAct because that's our own compatibility -// thing for people using react-dom@16.8.0. Anyone else doesn't need it and -// people should just upgrade anyway. - /* eslint func-name-matching:0 */ From 01ec70746a18b255ceef89f1d1db249fb3a49801 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Wed, 30 Mar 2022 17:05:31 +0200 Subject: [PATCH 10/10] feat: Support stable React 18 release (#1030) --- .github/workflows/validate.yml | 5 ++--- package.json | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index aafa6cad..45cc7d13 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,7 +12,7 @@ on: pull_request: {} jobs: main: - continue-on-error: ${{ matrix.react != 'current' }} + continue-on-error: ${{ matrix.react != 'latest' }} # ignore all-contributors PRs if: ${{ !contains(github.head_ref, 'all-contributors') }} strategy: @@ -20,7 +20,7 @@ jobs: matrix: # TODO: relax `'16.9.1'` to `16` once GitHub has 16.9.1 cached. 16.9.0 is broken due to https://github.com/nodejs/node/issues/40030 node: [12, 14, '16.9.1'] - react: [current, next, experimental] + react: [latest, next, experimental] runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs @@ -47,7 +47,6 @@ jobs: # https://reactjs.org/blog/2019/10/22/react-release-channels.html#using-the-next-channel-for-integration-testing - name: ⚛️ Setup react run: npm install react@${{ matrix.react }} react-dom@${{ matrix.react }} - if: ${{ matrix.react != 'current' }} - name: ▶️ Run validate script run: npm run validate diff --git a/package.json b/package.json index 9b9d5ebf..49d1fa7a 100644 --- a/package.json +++ b/package.json @@ -54,14 +54,14 @@ "dotenv-cli": "^4.0.0", "kcd-scripts": "^11.1.0", "npm-run-all": "^4.1.5", - "react": "18.0.0-rc.1", - "react-dom": "18.0.0-rc.1", + "react": "^18.0.0", + "react-dom": "^18.0.0", "rimraf": "^3.0.2", "typescript": "^4.1.2" }, "peerDependencies": { - "react": "18.0.0-rc.1", - "react-dom": "18.0.0-rc.1" + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js",