diff --git a/.all-contributorsrc b/.all-contributorsrc index cf2f3bdd..b22c9414 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -876,7 +876,8 @@ "profile": "https://nickmccurdy.com/", "contributions": [ "review", - "question" + "question", + "infra" ] }, { @@ -1121,8 +1122,269 @@ "contributions": [ "doc" ] + }, + { + "login": "kamranayub", + "name": "Kamran Ayub", + "avatar_url": "https://avatars1.githubusercontent.com/u/563819?v=4", + "profile": "http://kamranicus.com/", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "MatanBobi", + "name": "Matan Borenkraout", + "avatar_url": "https://avatars2.githubusercontent.com/u/12711091?v=4", + "profile": "https://twitter.com/matanbobi", + "contributions": [ + "code" + ] + }, + { + "login": "radar", + "name": "Ryan Bigg", + "avatar_url": "https://avatars3.githubusercontent.com/u/2687?v=4", + "profile": "http://ryanbigg.com", + "contributions": [ + "maintenance" + ] + }, + { + "login": "antonhalim", + "name": "Anton Halim", + "avatar_url": "https://avatars1.githubusercontent.com/u/10498035?v=4", + "profile": "https://antonhalim.com", + "contributions": [ + "doc" + ] + }, + { + "login": "artem-malko", + "name": "Artem Malko", + "avatar_url": "https://avatars0.githubusercontent.com/u/1823689?v=4", + "profile": "http://artmalko.ru", + "contributions": [ + "code" + ] + }, + { + "login": "ljosberinn", + "name": "Gerrit Alex", + "avatar_url": "https://avatars1.githubusercontent.com/u/29307652?v=4", + "profile": "http://gerritalex.de", + "contributions": [ + "code" + ] + }, + { + "login": "karthick3018", + "name": "Karthick Raja", + "avatar_url": "https://avatars1.githubusercontent.com/u/47154512?v=4", + "profile": "https://github.com/karthick3018", + "contributions": [ + "code" + ] + }, + { + "login": "theashraf", + "name": "Abdelrahman Ashraf", + "avatar_url": "https://avatars1.githubusercontent.com/u/39750790?v=4", + "profile": "https://github.com/theashraf", + "contributions": [ + "code" + ] + }, + { + "login": "lidoravitan", + "name": "Lidor Avitan", + "avatar_url": "https://avatars0.githubusercontent.com/u/35113398?v=4", + "profile": "https://github.com/lidoravitan", + "contributions": [ + "doc" + ] + }, + { + "login": "ljharb", + "name": "Jordan Harband", + "avatar_url": "https://avatars1.githubusercontent.com/u/45469?v=4", + "profile": "https://github.com/ljharb", + "contributions": [ + "review", + "ideas" + ] + }, + { + "login": "marcosvega91", + "name": "Marco Moretti", + "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", + "profile": "https://github.com/marcosvega91", + "contributions": [ + "code" + ] + }, + { + "login": "sanchit121", + "name": "sanchit121", + "avatar_url": "https://avatars2.githubusercontent.com/u/30828115?v=4", + "profile": "https://github.com/sanchit121", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "solufa", + "name": "Solufa", + "avatar_url": "https://avatars.githubusercontent.com/u/9402912?v=4", + "profile": "https://github.com/solufa", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "AriPerkkio", + "name": "Ari Perkkiö", + "avatar_url": "https://avatars.githubusercontent.com/u/14806298?v=4", + "profile": "https://codepen.io/ariperkkio/", + "contributions": [ + "test" + ] + }, + { + "login": "jhnns", + "name": "Johannes Ewald", + "avatar_url": "https://avatars.githubusercontent.com/u/781746?v=4", + "profile": "https://github.com/jhnns", + "contributions": [ + "code" + ] + }, + { + "login": "anpaopao", + "name": "Angus J. Pope", + "avatar_url": "https://avatars.githubusercontent.com/u/44686792?v=4", + "profile": "https://github.com/anpaopao", + "contributions": [ + "doc" + ] + }, + { + "login": "leschdom", + "name": "Dominik Lesch", + "avatar_url": "https://avatars.githubusercontent.com/u/62334278?v=4", + "profile": "https://github.com/leschdom", + "contributions": [ + "doc" + ] + }, + { + "login": "ImADrafter", + "name": "Marcos Gómez", + "avatar_url": "https://avatars.githubusercontent.com/u/44379989?v=4", + "profile": "https://github.com/ImADrafter", + "contributions": [ + "doc" + ] + }, + { + "login": "akashshyamdev", + "name": "Akash Shyam", + "avatar_url": "https://avatars.githubusercontent.com/u/56759828?v=4", + "profile": "https://www.akashshyam.online/", + "contributions": [ + "bug" + ] + }, + { + "login": "fmeum", + "name": "Fabian Meumertzheim", + "avatar_url": "https://avatars.githubusercontent.com/u/4312191?v=4", + "profile": "https://hen.ne.ke", + "contributions": [ + "code", + "bug" + ] + }, + { + "login": "Nokel81", + "name": "Sebastian Malton", + "avatar_url": "https://avatars.githubusercontent.com/u/8225332?v=4", + "profile": "https://github.com/Nokel81", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "mboettcher", + "name": "Martin Böttcher", + "avatar_url": "https://avatars.githubusercontent.com/u/2325337?v=4", + "profile": "https://github.com/mboettcher", + "contributions": [ + "code" + ] + }, + { + "login": "TkDodo", + "name": "Dominik Dorfmeister", + "avatar_url": "https://avatars.githubusercontent.com/u/1021430?v=4", + "profile": "http://tkdodo.eu", + "contributions": [ + "code" + ] + }, + { + "login": "stephensauceda", + "name": "Stephen Sauceda", + "avatar_url": "https://avatars.githubusercontent.com/u/1017723?v=4", + "profile": "https://stephensauceda.com", + "contributions": [ + "doc" + ] + }, + { + "login": "cmdcolin", + "name": "Colin Diesh", + "avatar_url": "https://avatars.githubusercontent.com/u/6511937?v=4", + "profile": "http://cmdcolin.github.io", + "contributions": [ + "doc" + ] + }, + { + "login": "yinm", + "name": "Yusuke Iinuma", + "avatar_url": "https://avatars.githubusercontent.com/u/13295106?v=4", + "profile": "http://yinm.info", + "contributions": [ + "code" + ] + }, + { + "login": "trappar", + "name": "Jeff Way", + "avatar_url": "https://avatars.githubusercontent.com/u/525726?v=4", + "profile": "https://github.com/trappar", + "contributions": [ + "code" + ] + }, + { + "login": "bernardobelchior", + "name": "Bernardo Belchior", + "avatar_url": "https://avatars.githubusercontent.com/u/12778398?v=4", + "profile": "http://belchior.me", + "contributions": [ + "code", + "doc" + ] } ], "contributorsPerLine": 7, - "repoHost": "https://github.com" + "repoHost": "https://github.com", + "commitType": "docs", + "commitConvention": "angular" } diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index f9286936..002bafb4 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,3 +1,5 @@ { - "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"] + "installCommand": "install:csb", + "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], + "node": "18" } diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 85d31914..496c8563 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -22,10 +22,8 @@ Thanks for your interest in the project. We appreciate bugs filed and PRs submit For questions related to using the library, please visit a support community instead of filing an issue on GitHub. You can follow the instructions in this codesandbox to make a reproduction of your issue: https://kcd.im/rtl-help - * React Spectrum - https://spectrum.chat/testing-library - * Reactiflux on Discord - https://www.reactiflux.com + * Discord + https://discord.gg/testing-library * Stack Overflow https://stackoverflow.com/questions/tagged/react-testing-library @@ -63,6 +61,7 @@ for the supported version. Relevant code or config ```javascript + ``` What you did: diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md index daefe8c6..c04bef38 100644 --- a/.github/ISSUE_TEMPLATE/Bug_Report.md +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -60,13 +60,8 @@ https://github.com/testing-library/testing-library-docs ### Reproduction: ### Problem description: diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md index 3a4d1ca2..e625486b 100644 --- a/.github/ISSUE_TEMPLATE/Question.md +++ b/.github/ISSUE_TEMPLATE/Question.md @@ -16,8 +16,7 @@ For questions related to using the library, please visit a support community instead of filing an issue on GitHub. You can follow the instructions in this codesandbox to make a reproduction of your issue: https://kcd.im/rtl-help -- React Spectrum https://spectrum.chat/react-testing-library -- Reactiflux on Discord https://www.reactiflux.com +- Discord https://discord.gg/testing-library - Stack Overflow https://stackoverflow.com/questions/tagged/react-testing-library - Documentation: https://github.com/testing-library/testing-library-docs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 371a86f6..f5000e21 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -37,7 +37,7 @@ merge of your pull request! - [ ] Documentation added to the [docs site](https://github.com/testing-library/testing-library-docs) - [ ] Tests -- [ ] Typescript definitions updated +- [ ] TypeScript definitions updated - [ ] Ready to be merged diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..f239c717 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,117 @@ +name: validate +on: + push: + branches: + # Match SemVer major release branches + # e.g. "12.x" or "8.x" + - '[0-9]+.x' + - 'main' + - 'next' + - 'next-major' + - 'beta' + - 'alpha' + - '!all-contributors/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: read # to fetch code (actions/checkout) + +jobs: + main: + continue-on-error: ${{ matrix.react != 'latest' }} + # ignore all-contributors PRs + if: ${{ !contains(github.head_ref, 'all-contributors') }} + strategy: + fail-fast: false + matrix: + node: [18, 20] + react: ['18.x', latest, canary, experimental] + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + # TODO: Can be removed if https://github.com/kentcdodds/kcd-scripts/pull/146 is released + - name: Verify format (`npm run format` committed?) + run: npm run format -- --check --no-write + + # as requested by the React team :) + # 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 }} + + - name: ⚛️ Setup react types + if: ${{ matrix.react != 'canary' && matrix.react != 'experimental' }} + run: + npm install @types/react@${{ matrix.react }} @types/react-dom@${{ + matrix.react }} + + - name: ▶️ Run validate script + run: npm run validate + + - name: ⬆️ Upload coverage report + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + flags: ${{ matrix.react }} + token: ${{ secrets.CODECOV_TOKEN }} + + release: + permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: write # to create release tags (cycjimmy/semantic-release-action) + issues: write # to post release that resolves an issue (cycjimmy/semantic-release-action) + + needs: main + runs-on: ubuntu-latest + if: + ${{ github.repository == 'testing-library/react-testing-library' && + github.event_name == 'push' }} + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + node-version: 14 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🏗 Run build script + run: npm run build + + - name: 🚀 Release + uses: cycjimmy/semantic-release-action@v2 + with: + semantic_version: 17 + branches: | + [ + '+([0-9])?(.{+([0-9]),x}).x', + 'main', + 'next', + 'next-major', + {name: 'beta', prerelease: true}, + {name: 'alpha', prerelease: true} + ] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 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 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ac04499f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: node_js -cache: npm -notifications: - email: false -node_js: - # technically we support 10.0.0, but some of our tooling doesn't - - 10.14.2 - - 12 - - 14 - - node -install: - - npm install - # as requested by the React team :) - # https://reactjs.org/blog/2019/10/22/react-release-channels.html#using-the-next-channel-for-integration-testing - - if [ "$TRAVIS_EVENT_TYPE" = "cron" ]; then npm install react@next - react-dom@next; fi -script: - - npm run validate - - npx codecov@3 -branches: - only: - - master - - beta - -jobs: - include: - - stage: release - node_js: 14 - script: kcd-scripts travis-release - if: fork = false diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 070cb5f6..47681ae0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,74 +2,127 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of -experience, nationality, personal appearance, race, religion, or sexual identity -and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our +community include: -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -- The use of sexualized language or imagery and unwelcome sexual attention or - advances -- Trolling, insulting/derogatory comments, and personal or political attacks +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment -- Publishing others' private information, such as a physical or electronic - address, without explicit permission +- Publishing others' private information, such as a physical or email address, + without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or reject +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, or to ban temporarily or permanently any -contributor for other behaviors that they deem inappropriate, threatening, -offensive, or harmful. +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at kent+coc@doddsfamily.us. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an -incident. Further details of specific enforcement policies may be posted -separately. +reported to the community leaders responsible for enforcement at +me+coc@kentcdodds.com. All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3867b62..e16e9d61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,20 +11,20 @@ series [How to Contribute to an Open Source Project on GitHub][egghead] 2. Run `npm run setup -s` to install dependencies and run validation 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` -> Tip: Keep your `master` branch pointing at the original repository and make -> pull requests from branches on your fork. To do this, run: +> Tip: Keep your `main` branch pointing at the original repository and make pull +> requests from branches on your fork. To do this, run: > > ``` > git remote add upstream https://github.com/testing-library/react-testing-library.git > git fetch upstream -> git branch --set-upstream-to=upstream/master master +> git branch --set-upstream-to=upstream/main main > ``` > > This will add the original repository as a "remote" called "upstream," Then -> fetch the git information from that remote, then set your local `master` -> branch to use the upstream master branch whenever you run `git pull`. Then you -> can make all of your pull request branches based on this `master` branch. -> Whenever you want to update your version of `master`, do a regular `git pull`. +> fetch the git information from that remote, then set your local `main` branch +> to use the upstream main branch whenever you run `git pull`. Then you can make +> all of your pull request branches based on this `main` branch. Whenever you +> want to update your version of `main`, do a regular `git pull`. ## Committing and Pushing changes @@ -35,11 +35,11 @@ sure to include those changes (if they exist) in your commit. ### Update Typings If your PR introduced some changes in the API, you are more than welcome to -modify the Typescript type definition to reflect those changes. Just modify the -`/types/index.d.ts` file accordingly. If you have never seen Typescript +modify the TypeScript type definition to reflect those changes. Just modify the +`/types/index.d.ts` file accordingly. If you have never seen TypeScript definitions before, you can read more about it in its [documentation pages](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html). -Though this library itself is not written in Typescript we use +Though this library itself is not written in TypeScript we use [dtslint](https://github.com/microsoft/dtslint) to lint our typings. ## Help needed @@ -50,6 +50,5 @@ Also, please watch the repo and respond to questions/bug reports/feature requests! Thanks! [egghead]: - https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github -[all-contributors]: https://github.com/all-contributors/all-contributors + https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github [issues]: https://github.com/testing-library/react-testing-library/issues diff --git a/LICENSE b/LICENSE index 4c43675b..ca399d57 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ The MIT License (MIT) -Copyright (c) 2017 Kent C. Dodds +Copyright (c) 2017-Present Kent C. Dodds Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4745997c..7e18d5dd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ height="80" width="80" alt="goat" - src="https://raw.githubusercontent.com/testing-library/react-testing-library/master/other/goat.png" + src="https://raw.githubusercontent.com/testing-library/react-testing-library/main/other/goat.png" /> @@ -26,12 +26,13 @@ practices.
[![Build Status][build-badge]][build] [![Code Coverage][coverage-badge]][coverage] -[![version][version-badge]][package] [![downloads][downloads-badge]][npmtrends] +[![version][version-badge]][package] +[![downloads][downloads-badge]][npmtrends] [![MIT License][license-badge]][license] - -[](#contributors) -[![PRs Welcome][prs-badge]][prs] [![Code of Conduct][coc-badge]][coc] -[![Join the community on Spectrum][spectrum-badge]][spectrum] +[![All Contributors][all-contributors-badge]](#contributors) +[![PRs Welcome][prs-badge]][prs] +[![Code of Conduct][coc-badge]][coc] +[![Discord][discord-badge]][discord] [![Watch on GitHub][github-watch-badge]][github-watch] [![Star on GitHub][github-star-badge]][github-star] @@ -43,7 +44,7 @@ practices.
diff --git a/src/__tests__/act.js b/src/__tests__/act.js
index 97438d77..5430f28b 100644
--- a/src/__tests__/act.js
+++ b/src/__tests__/act.js
@@ -1,5 +1,5 @@
-import React from 'react'
-import {render, fireEvent, screen} from '../'
+import * as React from 'react'
+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__/auto-cleanup-skip.js b/src/__tests__/auto-cleanup-skip.js
index e5ef35ae..5696d4e3 100644
--- a/src/__tests__/auto-cleanup-skip.js
+++ b/src/__tests__/auto-cleanup-skip.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import * as React from 'react'
let render
beforeAll(() => {
diff --git a/src/__tests__/auto-cleanup.js b/src/__tests__/auto-cleanup.js
index 1d0a7954..450a6136 100644
--- a/src/__tests__/auto-cleanup.js
+++ b/src/__tests__/auto-cleanup.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import * as React from 'react'
import {render} from '../'
// This just verifies that by importing RTL in an
@@ -9,5 +9,5 @@ test('first', () => {
})
test('second', () => {
- expect(document.body.innerHTML).toEqual('')
+ expect(document.body).toBeEmptyDOMElement()
})
diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js
index a870a42d..9f17c722 100644
--- a/src/__tests__/cleanup.js
+++ b/src/__tests__/cleanup.js
@@ -1,7 +1,7 @@
-import React from 'react'
+import * as React from 'react'
import {render, cleanup} from '../'
-test('cleans up the document', async () => {
+test('cleans up the document', () => {
const spy = jest.fn()
const divId = 'my-div'
@@ -17,12 +17,110 @@ test('cleans up the document', async () => {
}
render( )
- await cleanup()
- expect(document.body.innerHTML).toBe('')
+ cleanup()
+ expect(document.body).toBeEmptyDOMElement()
expect(spy).toHaveBeenCalledTimes(1)
})
-test('cleanup does not error when an element is not a child', async () => {
+test('cleanup does not error when an element is not a child', () => {
render(, {container: document.createElement('div')})
- await cleanup()
+ cleanup()
+})
+
+test('cleanup runs effect cleanup functions', () => {
+ const spy = jest.fn()
+
+ const Test = () => {
+ React.useEffect(() => spy)
+
+ return null
+ }
+
+ render( )
+ cleanup()
+ expect(spy).toHaveBeenCalledTimes(1)
+})
+
+describe('fake timers and missing act warnings', () => {
+ beforeEach(() => {
+ jest.resetAllMocks()
+ jest.spyOn(console, 'error').mockImplementation(() => {
+ // assert messages explicitly
+ })
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ jest.useRealTimers()
+ })
+
+ test('cleanup does not flush microtasks', () => {
+ const microTaskSpy = jest.fn()
+ function Test() {
+ const counter = 1
+ const [, setDeferredCounter] = React.useState(null)
+ React.useEffect(() => {
+ let cancelled = false
+ Promise.resolve().then(() => {
+ microTaskSpy()
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false positive
+ if (!cancelled) {
+ setDeferredCounter(counter)
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [counter])
+
+ return null
+ }
+ render( )
+
+ cleanup()
+
+ expect(microTaskSpy).toHaveBeenCalledTimes(0)
+ // console.error is mocked
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalledTimes(0)
+ })
+
+ test('cleanup does not swallow missing act warnings', () => {
+ const deferredStateUpdateSpy = jest.fn()
+ function Test() {
+ const counter = 1
+ const [, setDeferredCounter] = React.useState(null)
+ React.useEffect(() => {
+ let cancelled = false
+ setTimeout(() => {
+ deferredStateUpdateSpy()
+ // eslint-disable-next-line jest/no-conditional-in-test -- false-positive
+ if (!cancelled) {
+ setDeferredCounter(counter)
+ }
+ }, 0)
+
+ return () => {
+ cancelled = true
+ }
+ }, [counter])
+
+ return null
+ }
+ render( )
+
+ jest.runAllTimers()
+ cleanup()
+
+ expect(deferredStateUpdateSpy).toHaveBeenCalledTimes(1)
+ // console.error is mocked
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalledTimes(1)
+ // eslint-disable-next-line no-console
+ expect(console.error.mock.calls[0][0]).toMatch(
+ 'a test was not wrapped in act(...)',
+ )
+ })
})
diff --git a/src/__tests__/config.js b/src/__tests__/config.js
new file mode 100644
index 00000000..7fdb1e00
--- /dev/null
+++ b/src/__tests__/config.js
@@ -0,0 +1,66 @@
+import {configure, getConfig} from '../'
+
+describe('configuration API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ describe('DTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ })
+
+ test('configure can set by a function', () => {
+ // setup base option
+ const baseTestIdAttribute = 'data-testid'
+ configure({testIdAttribute: baseTestIdAttribute})
+
+ const modifiedPrefix = 'modified-'
+ configure(existingConfig => ({
+ testIdAttribute: `${modifiedPrefix}${existingConfig.testIdAttribute}`,
+ }))
+
+ expect(getConfig().testIdAttribute).toBe(
+ `${modifiedPrefix}${baseTestIdAttribute}`,
+ )
+ })
+ })
+
+ describe('RTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ configure({reactStrictMode: true})
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+
+ test('configure can set by a function', () => {
+ configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+ })
+
+ test('configure can set DTL and RTL options at once', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute, reactStrictMode: true})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+})
diff --git a/src/__tests__/debug.js b/src/__tests__/debug.js
index 088385b7..c6a1d1fe 100644
--- a/src/__tests__/debug.js
+++ b/src/__tests__/debug.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import * as React from 'react'
import {render, screen} from '../'
beforeEach(() => {
@@ -42,11 +42,14 @@ test('allows same arguments as prettyDOM', () => {
debug(container, 6, {highlight: false})
expect(console.log).toHaveBeenCalledTimes(1)
expect(console.log.mock.calls[0]).toMatchInlineSnapshot(`
- Array [
- "
- ...",
+ [
+
+ ...,
]
`)
})
-/* eslint no-console:0 */
+/*
+eslint
+ no-console: "off",
+*/
diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js
index cbbf0973..f93c23be 100644
--- a/src/__tests__/end-to-end.js
+++ b/src/__tests__/end-to-end.js
@@ -1,37 +1,234 @@
-import React from 'react'
-import {render, waitForElementToBeRemoved, screen} from '../'
-
-const fetchAMessage = () =>
- new Promise(resolve => {
- // we are using random timeout here to simulate a real-time example
- // of an async operation calling a callback at a non-deterministic time
- const randomTimeout = Math.floor(Math.random() * 100)
- setTimeout(() => {
- resolve({returnedMessage: 'Hello World'})
- }, randomTimeout)
- })
-
-class ComponentWithLoader extends React.Component {
- state = {loading: true}
- async componentDidMount() {
- const data = await fetchAMessage()
- this.setState({data, loading: false}) // eslint-disable-line
- }
- render() {
- if (this.state.loading) {
- return Loading...
+import * as React from 'react'
+import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in a macrotask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMacrotask = () =>
+ new Promise(resolve => {
+ // we are using random timeout here to simulate a real-time example
+ // of an async operation calling a callback at a non-deterministic time
+ const randomTimeout = Math.floor(Math.random() * 100)
+ setTimeout(() => {
+ resolve({returnedMessage: 'Hello World'})
+ }, randomTimeout)
+ })
+
+ function ComponentWithMacrotaskLoader() {
+ const [state, setState] = React.useState({data: undefined, loading: true})
+ React.useEffect(() => {
+ let cancelled = false
+ fetchAMessageInAMacrotask().then(data => {
+ if (!cancelled) {
+ setState({data, loading: false})
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (state.loading) {
+ return Loading...
+ }
+
+ return (
+
+ Loaded this message: {state.data.returnedMessage}!
+
+ )
+ }
+
+ test('waitForElementToBeRemoved', async () => {
+ render( )
+ const loading = () => screen.getByText('Loading...')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render( )
+ await waitFor(() => screen.getByText(/Loading../))
+ await waitFor(() => screen.getByText(/Loaded this message:/))
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render( )
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in many microtask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMicrotask = () =>
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({title: 'Hello World'}),
+ })
+
+ function ComponentWithMicrotaskLoader() {
+ const [fetchState, setFetchState] = React.useState({fetching: true})
+
+ React.useEffect(() => {
+ if (fetchState.fetching) {
+ fetchAMessageInAMicrotask().then(res => {
+ return (
+ res
+ .json()
+ // By spec, the runtime can only yield back to the event loop once
+ // the microtask queue is empty.
+ // So we ensure that we actually wait for that as well before yielding back from `waitFor`.
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ )
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return Loading..
+ }
+
+ return (
+ Loaded this message: {fetchState.todo}
+ )
+ }
+
+ test('waitForElementToBeRemoved', async () => {
+ render( )
+ const loading = () => screen.getByText('Loading..')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render( )
+ await waitFor(() => {
+ screen.getByText('Loading..')
+ })
+ await waitFor(() => {
+ screen.getByText(/Loaded this message:/)
+ })
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render( )
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in a microtask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMicrotask = () =>
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({title: 'Hello World'}),
+ })
+
+ function ComponentWithMicrotaskLoader() {
+ const [fetchState, setFetchState] = React.useState({fetching: true})
+
+ React.useEffect(() => {
+ if (fetchState.fetching) {
+ fetchAMessageInAMicrotask().then(res => {
+ return res.json().then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return Loading..
+ }
+
+ return (
+ Loaded this message: {fetchState.todo}
+ )
}
- return (
-
- Loaded this message: {this.state.data.returnedMessage}!
-
- )
- }
-}
-
-test('it waits for the data to be loaded', async () => {
- render( )
- const loading = () => screen.getByText('Loading...')
- await waitForElementToBeRemoved(loading)
- expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
-})
+
+ test('waitForElementToBeRemoved', async () => {
+ render( )
+ const loading = () => screen.getByText('Loading..')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render( )
+ await waitFor(() => {
+ screen.getByText('Loading..')
+ })
+ await waitFor(() => {
+ screen.getByText(/Loaded this message:/)
+ })
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render( )
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
diff --git a/src/__tests__/error-handlers.js b/src/__tests__/error-handlers.js
new file mode 100644
index 00000000..60db1410
--- /dev/null
+++ b/src/__tests__/error-handlers.js
@@ -0,0 +1,183 @@
+/* eslint-disable jest/no-if */
+/* eslint-disable jest/no-conditional-in-test */
+/* eslint-disable jest/no-conditional-expect */
+import * as React from 'react'
+import {render, renderHook} from '../'
+
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('render errors', () => {
+ function Thrower() {
+ throw new Error('Boom!')
+ }
+
+ if (isReact19) {
+ expect(() => {
+ render( )
+ }).toThrow('Boom!')
+ } else {
+ expect(() => {
+ expect(() => {
+ render( )
+ }).toThrow('Boom!')
+ }).toErrorDev([
+ 'Error: Uncaught [Error: Boom!]',
+ // React retries on error
+ 'Error: Uncaught [Error: Boom!]',
+ ])
+ }
+})
+
+test('onUncaughtError is not supported in render', () => {
+ function Thrower() {
+ throw new Error('Boom!')
+ }
+ const onUncaughtError = jest.fn(() => {})
+
+ expect(() => {
+ render( , {
+ onUncaughtError(error, errorInfo) {
+ console.log({error, errorInfo})
+ },
+ })
+ }).toThrow(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+
+ expect(onUncaughtError).toHaveBeenCalledTimes(0)
+})
+
+testGateReact19('onCaughtError is supported in render', () => {
+ const thrownError = new Error('Boom!')
+ const handleComponentDidCatch = jest.fn()
+ const onCaughtError = jest.fn()
+ class ErrorBoundary extends React.Component {
+ state = {error: null}
+ static getDerivedStateFromError(error) {
+ return {error}
+ }
+ componentDidCatch(error, errorInfo) {
+ handleComponentDidCatch(error, errorInfo)
+ }
+ render() {
+ if (this.state.error) {
+ return null
+ }
+ return this.props.children
+ }
+ }
+ function Thrower() {
+ throw thrownError
+ }
+
+ render(
+
+
+ ,
+ {
+ onCaughtError,
+ },
+ )
+
+ expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
+ componentStack: expect.any(String),
+ errorBoundary: expect.any(Object),
+ })
+})
+
+test('onRecoverableError is supported in render', () => {
+ const onRecoverableError = jest.fn()
+
+ const container = document.createElement('div')
+ container.innerHTML = 'server'
+ // We just hope we forwarded the callback correctly (which is guaranteed since we just pass it along)
+ // Frankly, I'm too lazy to assert on React 18 hydration errors since they're a mess.
+ // eslint-disable-next-line jest/no-conditional-in-test
+ if (isReact19) {
+ render(client, {
+ container,
+ hydrate: true,
+ onRecoverableError,
+ })
+ expect(onRecoverableError).toHaveBeenCalledTimes(1)
+ } else {
+ expect(() => {
+ render(client, {
+ container,
+ hydrate: true,
+ onRecoverableError,
+ })
+ }).toErrorDev(['', ''], {withoutStack: 1})
+ expect(onRecoverableError).toHaveBeenCalledTimes(2)
+ }
+})
+
+test('onUncaughtError is not supported in renderHook', () => {
+ function useThrower() {
+ throw new Error('Boom!')
+ }
+ const onUncaughtError = jest.fn(() => {})
+
+ expect(() => {
+ renderHook(useThrower, {
+ onUncaughtError(error, errorInfo) {
+ console.log({error, errorInfo})
+ },
+ })
+ }).toThrow(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+
+ expect(onUncaughtError).toHaveBeenCalledTimes(0)
+})
+
+testGateReact19('onCaughtError is supported in renderHook', () => {
+ const thrownError = new Error('Boom!')
+ const handleComponentDidCatch = jest.fn()
+ const onCaughtError = jest.fn()
+ class ErrorBoundary extends React.Component {
+ state = {error: null}
+ static getDerivedStateFromError(error) {
+ return {error}
+ }
+ componentDidCatch(error, errorInfo) {
+ handleComponentDidCatch(error, errorInfo)
+ }
+ render() {
+ if (this.state.error) {
+ return null
+ }
+ return this.props.children
+ }
+ }
+ function useThrower() {
+ throw thrownError
+ }
+
+ renderHook(useThrower, {
+ onCaughtError,
+ wrapper: ErrorBoundary,
+ })
+
+ expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
+ componentStack: expect.any(String),
+ errorBoundary: expect.any(Object),
+ })
+})
+
+// Currently, there's no recoverable error without hydration.
+// The option is still supported though.
+test('onRecoverableError is supported in renderHook', () => {
+ const onRecoverableError = jest.fn()
+
+ renderHook(
+ () => {
+ // TODO: trigger recoverable error
+ },
+ {
+ onRecoverableError,
+ },
+ )
+})
diff --git a/src/__tests__/events.js b/src/__tests__/events.js
index dc529344..587bfdae 100644
--- a/src/__tests__/events.js
+++ b/src/__tests__/events.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import * as React from 'react'
import {render, fireEvent} from '../'
const eventTypes = [
@@ -62,6 +62,22 @@ const eventTypes = [
],
elementType: 'button',
},
+ {
+ type: 'Pointer',
+ events: [
+ 'pointerOver',
+ 'pointerEnter',
+ 'pointerDown',
+ 'pointerMove',
+ 'pointerUp',
+ 'pointerCancel',
+ 'pointerOut',
+ 'pointerLeave',
+ 'gotPointerCapture',
+ 'lostPointerCapture',
+ ],
+ elementType: 'button',
+ },
{
type: 'Selection',
events: ['select'],
@@ -211,3 +227,30 @@ test('calling `fireEvent` directly works too', () => {
}),
)
})
+
+test('blur/focus bubbles in react', () => {
+ const handleBlur = jest.fn()
+ const handleBubbledBlur = jest.fn()
+ const handleFocus = jest.fn()
+ const handleBubbledFocus = jest.fn()
+ const {container} = render(
+
+
+ ,
+ )
+ const button = container.firstChild.firstChild
+
+ fireEvent.focus(button)
+
+ expect(handleBlur).toHaveBeenCalledTimes(0)
+ expect(handleBubbledBlur).toHaveBeenCalledTimes(0)
+ expect(handleFocus).toHaveBeenCalledTimes(1)
+ expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
+
+ fireEvent.blur(button)
+
+ expect(handleBlur).toHaveBeenCalledTimes(1)
+ expect(handleBubbledBlur).toHaveBeenCalledTimes(1)
+ expect(handleFocus).toHaveBeenCalledTimes(1)
+ expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
+})
diff --git a/src/__tests__/multi-base.js b/src/__tests__/multi-base.js
index c45bcd37..ef5a7e11 100644
--- a/src/__tests__/multi-base.js
+++ b/src/__tests__/multi-base.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import * as React from 'react'
import {render} from '../'
// these are created once per test suite and reused for each case
@@ -37,3 +37,9 @@ test('baseElement isolates trees from one another', () => {
'Unable to find an element with the text: Hyde.',
)
})
+
+// https://github.com/testing-library/eslint-plugin-testing-library/issues/188
+/*
+eslint
+ testing-library/prefer-screen-queries: "off",
+*/
diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js
index 56ce4970..0464ad24 100644
--- a/src/__tests__/new-act.js
+++ b/src/__tests__/new-act.js
@@ -1,19 +1,22 @@
let asyncAct
-jest.mock('react-dom/test-utils', () => ({
- act: cb => {
- return cb()
- },
-}))
+jest.mock('react', () => {
+ return {
+ ...jest.requireActual('react'),
+ act: cb => {
+ return cb()
+ },
+ }
+})
beforeEach(() => {
jest.resetModules()
- asyncAct = require('../act-compat').asyncAct
+ asyncAct = require('../act-compat').default
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
- console.error.mockRestore()
+ jest.restoreAllMocks()
})
test('async act works when it does not exist (older versions of react)', async () => {
@@ -47,9 +50,9 @@ test('async act recovers from errors', async () => {
}
expect(console.error).toHaveBeenCalledTimes(1)
expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- "call console.error",
+ [
+ [
+ call console.error,
],
]
`)
@@ -65,9 +68,9 @@ test('async act recovers from sync errors', async () => {
}
expect(console.error).toHaveBeenCalledTimes(1)
expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- "call console.error",
+ [
+ [
+ call console.error,
],
]
`)
diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js
deleted file mode 100644
index 039a79ae..00000000
--- a/src/__tests__/no-act.js
+++ /dev/null
@@ -1,80 +0,0 @@
-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-dom/test-utils', () => ({}))
-
-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(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
deleted file mode 100644
index b3de9377..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 54916ba4..6f5b5b39 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -1,89 +1,299 @@
-import React from 'react'
+import * as React from 'react'
import ReactDOM from 'react-dom'
-import {render, screen} from '../'
+import ReactDOMServer from 'react-dom/server'
+import {fireEvent, render, screen, configure} from '../'
-test('renders div into document', () => {
- const ref = React.createRef()
- const {container} = render()
- expect(container.firstChild).toBe(ref.current)
-})
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
-test('works great with react portals', () => {
- class MyPortal extends React.Component {
- constructor(...args) {
- super(...args)
- this.portalNode = document.createElement('div')
- this.portalNode.dataset.testid = 'my-portal'
- }
- componentDidMount() {
- document.body.appendChild(this.portalNode)
- }
- componentWillUnmount() {
- this.portalNode.parentNode.removeChild(this.portalNode)
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
+
+describe('render API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('renders div into document', () => {
+ const ref = React.createRef()
+ const {container} = render()
+ expect(container.firstChild).toBe(ref.current)
+ })
+
+ test('works great with react portals', () => {
+ class MyPortal extends React.Component {
+ constructor(...args) {
+ super(...args)
+ this.portalNode = document.createElement('div')
+ this.portalNode.dataset.testid = 'my-portal'
+ }
+ componentDidMount() {
+ document.body.appendChild(this.portalNode)
+ }
+ componentWillUnmount() {
+ this.portalNode.parentNode.removeChild(this.portalNode)
+ }
+ render() {
+ return ReactDOM.createPortal(
+ ,
+ this.portalNode,
+ )
+ }
}
- render() {
- return ReactDOM.createPortal(
- ,
- this.portalNode,
+
+ function Greet({greeting, subject}) {
+ return (
+
+
+ {greeting} {subject}
+
+
)
}
- }
-
- function Greet({greeting, subject}) {
- return (
-
-
- {greeting} {subject}
-
-
+
+ const {unmount} = render( )
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
+ const portalNode = screen.getByTestId('my-portal')
+ expect(portalNode).toBeInTheDocument()
+ unmount()
+ expect(portalNode).not.toBeInTheDocument()
+ })
+
+ test('returns baseElement which defaults to document.body', () => {
+ const {baseElement} = render()
+ expect(baseElement).toBe(document.body)
+ })
+
+ test('supports fragments', () => {
+ class Test extends React.Component {
+ render() {
+ return (
+
+ DocumentFragment
is pretty cool!
+
+ )
+ }
+ }
+
+ const {asFragment} = render( )
+ expect(asFragment()).toMatchSnapshot()
+ })
+
+ test('renders options.wrapper around node', () => {
+ const WrapperComponent = ({children}) => (
+ {children}
)
- }
-
- const {unmount} = render( )
- expect(screen.getByText('Hello World')).toBeInTheDocument()
- const portalNode = screen.getByTestId('my-portal')
- expect(portalNode).toBeInTheDocument()
- unmount()
- expect(portalNode).not.toBeInTheDocument()
-})
-test('returns baseElement which defaults to document.body', () => {
- const {baseElement} = render()
- expect(baseElement).toBe(document.body)
-})
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `)
+ })
+
+ test('renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `)
+ })
+
+ test('renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
+
+ render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
+
+ test('flushes useEffect cleanup functions sync on unmount()', () => {
+ const spy = jest.fn()
+ function Component() {
+ React.useEffect(() => spy, [])
+ return null
+ }
+ const {unmount} = render( )
+ expect(spy).toHaveBeenCalledTimes(0)
+
+ unmount()
+
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ 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()
+ })
+
+ test('hydrate will make the UI interactive', () => {
+ function App() {
+ const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
-test('supports fragments', () => {
- class Test extends React.Component {
- render() {
return (
-
- DocumentFragment
is pretty cool!
-
+
)
}
- }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
- const {asFragment} = render( )
- expect(asFragment()).toMatchSnapshot()
-})
+ expect(container).toHaveTextContent('clicked:0')
+
+ render(ui, {container, hydrate: true})
-test('renders options.wrapper around node', () => {
- const WrapperComponent = ({children}) => (
- {children}
- )
-
- const {container} = render(, {
- wrapper: WrapperComponent,
- })
-
- expect(screen.getByTestId('wrapper')).toBeInTheDocument()
- expect(container.firstChild).toMatchInlineSnapshot(`
-
-
-
-`)
+ fireEvent.click(container.querySelector('button'))
+
+ 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)
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toErrorDev(
+ [
+ "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",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
+ )
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toErrorDev(
+ [
+ "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",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws even with hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
+ )
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: false})
+
+ render(ui, {wrapper: WrapperComponent, reactStrictMode: true})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
+
+ test('reactStrictMode in config is used when renderOptions does not specify reactStrictMode', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: true})
+
+ render(ui, {wrapper: WrapperComponent})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js
new file mode 100644
index 00000000..f331e90e
--- /dev/null
+++ b/src/__tests__/renderHook.js
@@ -0,0 +1,141 @@
+import React, {useEffect} from 'react'
+import {configure, renderHook} from '../pure'
+
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('gives committed result', () => {
+ const {result} = renderHook(() => {
+ const [state, setState] = React.useState(1)
+
+ React.useEffect(() => {
+ setState(2)
+ }, [])
+
+ return [state, setState]
+ })
+
+ expect(result.current).toEqual([2, expect.any(Function)])
+})
+
+test('allows rerendering', () => {
+ const {result, rerender} = renderHook(
+ ({branch}) => {
+ const [left, setLeft] = React.useState('left')
+ const [right, setRight] = React.useState('right')
+
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
+ switch (branch) {
+ case 'left':
+ return [left, setLeft]
+ case 'right':
+ return [right, setRight]
+
+ default:
+ throw new Error(
+ 'No Props passed. This is a bug in the implementation',
+ )
+ }
+ },
+ {initialProps: {branch: 'left'}},
+ )
+
+ expect(result.current).toEqual(['left', expect.any(Function)])
+
+ rerender({branch: 'right'})
+
+ expect(result.current).toEqual(['right', expect.any(Function)])
+})
+
+test('allows wrapper components', async () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ const {result} = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ },
+ )
+
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ let result
+ expect(() => {
+ result = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toErrorDev(
+ [
+ "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",
+ ],
+ {withoutStack: true},
+ )
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact19('legacyRoot throws', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ expect(() => {
+ renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
+ )
+})
+
+describe('reactStrictMode', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const hookMountEffect = jest.fn()
+ configure({reactStrictMode: false})
+
+ renderHook(() => useEffect(() => hookMountEffect()), {
+ reactStrictMode: true,
+ })
+
+ expect(hookMountEffect).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/__tests__/rerender.js b/src/__tests__/rerender.js
index 7cb9156d..6c48c4dd 100644
--- a/src/__tests__/rerender.js
+++ b/src/__tests__/rerender.js
@@ -1,31 +1,98 @@
-import React from 'react'
-import {render} from '../'
-
-test('rerender will re-render the element', () => {
- const Greeting = props => {props.message}
- const {container, rerender} = render( )
- expect(container.firstChild).toHaveTextContent('hi')
- rerender( )
- expect(container.firstChild).toHaveTextContent('hey')
-})
+import * as React from 'react'
+import {render, configure} from '../'
+
+describe('rerender API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('rerender will re-render the element', () => {
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( )
+ expect(container.firstChild).toHaveTextContent('hi')
+ rerender( )
+ expect(container.firstChild).toHaveTextContent('hey')
+ })
+
+ test('hydrate will not update props until next render', () => {
+ const initialInputElement = document.createElement('input')
+ const container = document.createElement('div')
+ container.appendChild(initialInputElement)
+ document.body.appendChild(container)
+
+ const firstValue = 'hello'
+ initialInputElement.value = firstValue
-test('hydrate will not update props until next render', () => {
- const initialInputElement = document.createElement('input')
- const container = document.createElement('div')
- container.appendChild(initialInputElement)
- document.body.appendChild(container)
+ const {rerender} = render( null} />, {
+ container,
+ hydrate: true,
+ })
- const firstValue = 'hello'
- initialInputElement.value = firstValue
+ expect(initialInputElement).toHaveValue(firstValue)
- const {rerender} = render( null} />, {
- container,
- hydrate: true,
+ const secondValue = 'goodbye'
+ rerender( null} />)
+ expect(initialInputElement).toHaveValue(secondValue)
})
- expect(initialInputElement.value).toBe(firstValue)
+ test('re-renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- const secondValue = 'goodbye'
- rerender( null} />)
- expect(initialInputElement.value).toBe(secondValue)
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( , {
+ wrapper: WrapperComponent,
+ })
+
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hi
+
+
+ `)
+
+ rerender( )
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hey
+
+
+ `)
+ })
+
+ test('re-renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
+
+ const {rerender} = render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+
+ spy.mockClear()
+ rerender( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js
index a9869ec4..e3eaebbe 100644
--- a/src/__tests__/stopwatch.js
+++ b/src/__tests__/stopwatch.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import * as React from 'react'
import {render, fireEvent, screen} from '../'
class StopWatch extends React.Component {
@@ -40,18 +40,15 @@ class StopWatch extends React.Component {
const sleep = t => new Promise(resolve => setTimeout(resolve, t))
test('unmounts a component', async () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
const {unmount, container} = render( )
fireEvent.click(screen.getByText('Start'))
unmount()
// hey there reader! You don't need to have an assertion like this one
// this is just me making sure that the unmount function works.
// You don't need to do this in your apps. Just rely on the fact that this works.
- expect(container.innerHTML).toBe('')
+ expect(container).toBeEmptyDOMElement()
// just wait to see if the interval is cleared or not
// if it's not, then we'll call setState on an unmounted component
// and get an error.
await sleep(5)
- // eslint-disable-next-line no-console
- expect(console.error).not.toHaveBeenCalled()
})
diff --git a/src/act-compat.js b/src/act-compat.js
index e999ecfe..6eaec0fb 100644
--- a/src/act-compat.js
+++ b/src/act-compat.js
@@ -1,135 +1,91 @@
-import React from 'react'
-import ReactDOM from 'react-dom'
-import * as testUtils from 'react-dom/test-utils'
+import * as React from 'react'
+import * as DeprecatedReactTestUtils from 'react-dom/test-utils'
-const reactAct = testUtils.act
-const actSupported = reactAct !== undefined
+const reactAct =
+ typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act
-// 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 */
+ if (typeof globalThis !== 'undefined') {
+ return globalThis
+ }
+ /* istanbul ignore next */
+ 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')
}
-const act = reactAct || actPolyfill
+function setIsReactActEnvironment(isReactActEnvironment) {
+ getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment
+}
-let youHaveBeenWarned = false
-let isAsyncActSupported = null
+function getIsReactActEnvironment() {
+ return getGlobalThis().IS_REACT_ACT_ENVIRONMENT
+}
-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 = reactAct(() => {
- cbReturn = cb()
- return cbReturn
- })
- } catch (err) {
- console.error = originalConsoleError
- reject(err)
- return
+function withGlobalActEnvironment(actImplementation) {
+ return callback => {
+ const previousActEnvironment = getIsReactActEnvironment()
+ setIsReactActEnvironment(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
}
-
- 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.`,
+ return result
+ })
+ if (callbackNeedsToBeAwaited) {
+ const thenable = actResult
+ return {
+ then: (resolve, reject) => {
+ thenable.then(
+ returnValue => {
+ setIsReactActEnvironment(previousActEnvironment)
+ resolve(returnValue)
+ },
+ error => {
+ setIsReactActEnvironment(previousActEnvironment)
+ reject(error)
+ },
)
- 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(() => {})
- })
- })
+ } else {
+ 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.
+ setIsReactActEnvironment(previousActEnvironment)
+ throw error
}
- // 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(reactAct)
+
export default act
-export {asyncAct}
+export {
+ setIsReactActEnvironment as setReactActEnvironment,
+ getIsReactActEnvironment,
+}
/* eslint no-console:0 */
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 00000000..dc8a5035
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,34 @@
+import {
+ getConfig as getConfigDTL,
+ configure as configureDTL,
+} from '@testing-library/dom'
+
+let configForRTL = {
+ reactStrictMode: false,
+}
+
+function getConfig() {
+ return {
+ ...getConfigDTL(),
+ ...configForRTL,
+ }
+}
+
+function configure(newConfig) {
+ if (typeof newConfig === 'function') {
+ // Pass the existing config out to the provided function
+ // and accept a delta in return
+ newConfig = newConfig(getConfig())
+ }
+
+ const {reactStrictMode, ...configForDTL} = newConfig
+
+ configureDTL(configForDTL)
+
+ configForRTL = {
+ ...configForRTL,
+ reactStrictMode,
+ }
+}
+
+export {getConfig, configure}
diff --git a/src/fire-event.js b/src/fire-event.js
index 071aff8a..cb790c7f 100644
--- a/src/fire-event.js
+++ b/src/fire-event.js
@@ -24,6 +24,17 @@ fireEvent.mouseLeave = (...args) => {
return fireEvent.mouseOut(...args)
}
+const pointerEnter = fireEvent.pointerEnter
+const pointerLeave = fireEvent.pointerLeave
+fireEvent.pointerEnter = (...args) => {
+ pointerEnter(...args)
+ return fireEvent.pointerOver(...args)
+}
+fireEvent.pointerLeave = (...args) => {
+ pointerLeave(...args)
+ return fireEvent.pointerOut(...args)
+}
+
const select = fireEvent.select
fireEvent.select = (node, init) => {
select(node, init)
@@ -41,4 +52,18 @@ fireEvent.select = (node, init) => {
fireEvent.keyUp(node, init)
}
+// React event system tracks native focusout/focusin events for
+// running blur/focus handlers
+// @link https://github.com/facebook/react/pull/19186
+const blur = fireEvent.blur
+const focus = fireEvent.focus
+fireEvent.blur = (...args) => {
+ fireEvent.focusOut(...args)
+ return blur(...args)
+}
+fireEvent.focus = (...args) => {
+ fireEvent.focusIn(...args)
+ return focus(...args)
+}
+
export {fireEvent}
diff --git a/src/flush-microtasks.js b/src/flush-microtasks.js
deleted file mode 100644
index e309fe6a..00000000
--- a/src/flush-microtasks.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* istanbul ignore file */
-// the part of this file that we need tested is definitely being run
-// and the part that is not cannot easily have useful tests written
-// anyway. So we're just going to ignore coverage for this file
-/**
- * copied from React's enqueueTask.js
- */
-
-let didWarnAboutMessageChannel = false
-let enqueueTask
-try {
- // read require off the module object to get around the bundlers.
- // we don't want them to detect a require and bundle a Node polyfill.
- const requireString = `require${Math.random()}`.slice(0, 7)
- const nodeRequire = module && module[requireString]
- // assuming we're in node, let's try to get node's
- // version of setImmediate, bypassing fake timers if any.
- enqueueTask = nodeRequire.call(module, 'timers').setImmediate
-} catch (_err) {
- // we're in a browser
- // we can't use regular timers because they may still be faked
- // so we try MessageChannel+postMessage instead
- enqueueTask = callback => {
- const supportsMessageChannel = typeof MessageChannel === 'function'
- if (supportsMessageChannel) {
- const channel = new MessageChannel()
- channel.port1.onmessage = callback
- channel.port2.postMessage(undefined)
- } else if (didWarnAboutMessageChannel === false) {
- didWarnAboutMessageChannel = true
-
- // eslint-disable-next-line no-console
- console.error(
- 'This browser does not have a MessageChannel implementation, ' +
- 'so enqueuing tasks via await act(async () => ...) will fail. ' +
- 'Please file an issue at https://github.com/facebook/react/issues ' +
- 'if you encounter this warning.',
- )
- }
- }
-}
-
-export default function flushMicroTasks() {
- return {
- then(resolve) {
- enqueueTask(resolve)
- },
- }
-}
diff --git a/src/index.js b/src/index.js
index b69f6555..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
@@ -5,19 +6,34 @@ import {cleanup} from './pure'
// this ensures that tests run in isolation from each other
// if you don't like this then either import the `pure` module
// or set the RTL_SKIP_AUTO_CLEANUP env variable to 'true'.
-if (!process.env.RTL_SKIP_AUTO_CLEANUP) {
+if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) {
// ignore teardown() in code coverage because Jest does not support it
/* istanbul ignore else */
if (typeof afterEach === 'function') {
- afterEach(async () => {
- await cleanup()
+ afterEach(() => {
+ cleanup()
})
} else if (typeof teardown === 'function') {
// Block is guarded by `typeof` check.
// eslint does not support `typeof` guards.
// eslint-disable-next-line no-undef
- teardown(async () => {
- await cleanup()
+ teardown(() => {
+ 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)
})
}
}
diff --git a/src/pure.js b/src/pure.js
index fe7e6cf9..0f9c487d 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -1,21 +1,61 @@
-import React from 'react'
+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} from './act-compat'
+import act, {
+ getIsReactActEnvironment,
+ setReactActEnvironment,
+} from './act-compat'
import {fireEvent} from './fire-event'
-import flush from './flush-microtasks'
+import {getConfig, configure} from './config'
+
+function jestFakeTimersAreEnabled() {
+ /* istanbul ignore else */
+ if (typeof jest !== 'undefined' && jest !== null) {
+ return (
+ // legacy timers
+ setTimeout._isMockFunction === true || // modern timers
+ // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support.
+ Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
+ )
+ } // istanbul ignore next
+
+ return false
+}
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 {
+ const result = await cb()
+ // Drain microtask queue.
+ // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call.
+ // The caller would have no chance to wrap the in-flight Promises in `act()`
+ await new Promise(resolve => {
+ setTimeout(() => {
+ resolve()
+ }, 0)
+
+ if (jestFakeTimersAreEnabled()) {
+ jest.advanceTimersByTime(0)
+ }
+ })
+
+ return result
+ } finally {
+ setReactActEnvironment(previousActEnvironment)
+ }
},
eventWrapper: cb => {
let result
@@ -26,42 +66,121 @@ configureDTL({
},
})
+// 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,
+function strictModeIfNeeded(innerElement, reactStrictMode) {
+ return reactStrictMode ?? getConfig().reactStrictMode
+ ? React.createElement(React.StrictMode, null, innerElement)
+ : innerElement
+}
+
+function wrapUiIfNeeded(innerElement, wrapperComponent) {
+ return wrapperComponent
+ ? React.createElement(wrapperComponent, null, innerElement)
+ : innerElement
+}
+
+function createConcurrentRoot(
+ container,
{
- container,
- baseElement = container,
- queries,
- hydrate = false,
+ hydrate,
+ onCaughtError,
+ onRecoverableError,
+ ui,
wrapper: WrapperComponent,
- } = {},
+ reactStrictMode,
+ },
) {
- 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
+ if (hydrate) {
+ act(() => {
+ root = ReactDOMClient.hydrateRoot(
+ container,
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ {onCaughtError, onRecoverableError},
+ )
+ })
+ } else {
+ root = ReactDOMClient.createRoot(container, {
+ onCaughtError,
+ onRecoverableError,
+ })
}
- // 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)
+ return {
+ hydrate() {
+ /* istanbul ignore if */
+ if (!hydrate) {
+ throw new Error(
+ 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.',
+ )
+ }
+ // Nothing to do since hydration happens when creating the root object.
+ },
+ render(element) {
+ root.render(element)
+ },
+ unmount() {
+ root.unmount()
+ },
+ }
+}
- const wrapUiIfNeeded = innerElement =>
- WrapperComponent
- ? React.createElement(WrapperComponent, null, innerElement)
- : innerElement
+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,
+ reactStrictMode,
+ },
+) {
act(() => {
if (hydrate) {
- ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
+ root.hydrate(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
} else {
- ReactDOM.render(wrapUiIfNeeded(ui), container)
+ root.render(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
}
})
@@ -74,9 +193,19 @@ function render(
el.forEach(e => console.log(prettyDOM(e, maxLength, options)))
: // eslint-disable-next-line no-console,
console.log(prettyDOM(el, maxLength, options)),
- unmount: () => ReactDOM.unmountComponentAtNode(container),
+ unmount: () => {
+ act(() => {
+ root.unmount()
+ })
+ },
rerender: rerenderUi => {
- render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
+ renderRoot(rerenderUi, {
+ container,
+ baseElement,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ })
// 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
},
@@ -96,27 +225,139 @@ function render(
}
}
-async function cleanup() {
- await flush()
- mountedContainers.forEach(cleanupAtContainer)
+function render(
+ ui,
+ {
+ container,
+ baseElement = container,
+ legacyRoot = false,
+ onCaughtError,
+ onUncaughtError,
+ onRecoverableError,
+ queries,
+ hydrate = false,
+ wrapper,
+ reactStrictMode,
+ } = {},
+) {
+ if (onUncaughtError !== undefined) {
+ throw new Error(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+ }
+ if (legacyRoot && typeof ReactDOM.render !== 'function') {
+ const error = new Error(
+ '`legacyRoot: true` is not supported in this version of React. ' +
+ 'If your app runs React 19 or later, you should remove this flag. ' +
+ 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.',
+ )
+ Error.captureStackTrace(error, render)
+ throw error
+ }
+
+ 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,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper,
+ reactStrictMode,
+ })
+
+ 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 => {
+ // 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
+ }
+ })
+ }
+
+ return renderRoot(ui, {
+ container,
+ baseElement,
+ queries,
+ hydrate,
+ wrapper,
+ root,
+ reactStrictMode,
+ })
+}
+
+function cleanup() {
+ mountedRootEntries.forEach(({root, container}) => {
+ act(() => {
+ root.unmount()
+ })
+ if (container.parentNode === document.body) {
+ document.body.removeChild(container)
+ }
+ })
+ mountedRootEntries.length = 0
+ mountedContainers.clear()
}
-// 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) {
- ReactDOM.unmountComponentAtNode(container)
- if (container.parentNode === document.body) {
- document.body.removeChild(container)
+function renderHook(renderCallback, options = {}) {
+ const {initialProps, ...renderOptions} = options
+
+ if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') {
+ const error = new Error(
+ '`legacyRoot: true` is not supported in this version of React. ' +
+ 'If your app runs React 19 or later, you should remove this flag. ' +
+ 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.',
+ )
+ Error.captureStackTrace(error, renderHook)
+ throw error
+ }
+
+ const result = React.createRef()
+
+ function TestComponent({renderCallbackProps}) {
+ const pendingResult = renderCallback(renderCallbackProps)
+
+ React.useEffect(() => {
+ result.current = pendingResult
+ })
+
+ return null
+ }
+
+ const {rerender: baseRerender, unmount} = render(
+ ,
+ renderOptions,
+ )
+
+ function rerender(rerenderCallbackProps) {
+ return baseRerender(
+ ,
+ )
}
- mountedContainers.delete(container)
+
+ return {result, rerender, unmount}
}
// just re-export everything from dom-testing-library
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.
+export {render, renderHook, cleanup, act, fireEvent, getConfig, configure}
/* eslint func-name-matching:0 */
diff --git a/tests/failOnUnexpectedConsoleCalls.js b/tests/failOnUnexpectedConsoleCalls.js
new file mode 100644
index 00000000..83e0c641
--- /dev/null
+++ b/tests/failOnUnexpectedConsoleCalls.js
@@ -0,0 +1,129 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/setupTests.js#L71-L161
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+const util = require('util')
+const chalk = require('chalk')
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
+ const newMethod = function (format, ...args) {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
+ return
+ }
+
+ // Capture the call stack now so we can warn about it later.
+ // The call stack has helpful information for the test author.
+ // Don't throw yet though b'c it might be accidentally caught and suppressed.
+ const stack = new Error().stack
+ unexpectedConsoleCallStacks.push([
+ stack.substr(stack.indexOf('\n') + 1),
+ util.format(format, ...args),
+ ])
+ }
+
+ console[methodName] = newMethod
+
+ return newMethod
+}
+
+const isSpy = spy =>
+ (spy.calls && typeof spy.calls.count === 'function') ||
+ spy._isMockFunction === true
+
+const flushUnexpectedConsoleCalls = (
+ mockMethod,
+ methodName,
+ expectedMatcher,
+ unexpectedConsoleCallStacks,
+) => {
+ if (console[methodName] !== mockMethod && !isSpy(console[methodName])) {
+ throw new Error(
+ `Test did not tear down console.${methodName} mock properly.`,
+ )
+ }
+ if (unexpectedConsoleCallStacks.length > 0) {
+ const messages = unexpectedConsoleCallStacks.map(
+ ([stack, message]) =>
+ `${chalk.red(message)}\n` +
+ `${stack
+ .split('\n')
+ .map(line => chalk.gray(line))
+ .join('\n')}`,
+ )
+
+ const message =
+ `Expected test not to call ${chalk.bold(
+ `console.${methodName}()`,
+ )}.\n\n` +
+ 'If the warning is expected, test for it explicitly by:\n' +
+ `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
+ `matcher, or...\n` +
+ `2. Mock it out using ${chalk.bold(
+ 'spyOnDev',
+ )}(console, '${methodName}') or ${chalk.bold(
+ 'spyOnProd',
+ )}(console, '${methodName}'), and test that the warning occurs.`
+
+ throw new Error(`${message}\n\n${messages.join('\n\n')}`)
+ }
+}
+
+const unexpectedErrorCallStacks = []
+const unexpectedWarnCallStacks = []
+
+const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks)
+const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks)
+
+const flushAllUnexpectedConsoleCalls = () => {
+ flushUnexpectedConsoleCalls(
+ errorMethod,
+ 'error',
+ 'toErrorDev',
+ unexpectedErrorCallStacks,
+ )
+ flushUnexpectedConsoleCalls(
+ warnMethod,
+ 'warn',
+ 'toWarnDev',
+ unexpectedWarnCallStacks,
+ )
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+const resetAllUnexpectedConsoleCalls = () => {
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+expect.extend({
+ ...require('./toWarnDev'),
+})
+
+beforeEach(resetAllUnexpectedConsoleCalls)
+afterEach(flushAllUnexpectedConsoleCalls)
diff --git a/tests/setup-env.js b/tests/setup-env.js
index 264828a9..1a4401de 100644
--- a/tests/setup-env.js
+++ b/tests/setup-env.js
@@ -1 +1,9 @@
import '@testing-library/jest-dom/extend-expect'
+import './failOnUnexpectedConsoleCalls'
+import {TextEncoder} from 'util'
+import {MessageChannel} from 'worker_threads'
+
+global.TextEncoder = TextEncoder
+// TODO: Revisit once https://github.com/jsdom/jsdom/issues/2448 is resolved
+// This isn't perfect but good enough.
+global.MessageChannel = MessageChannel
diff --git a/tests/shouldIgnoreConsoleError.js b/tests/shouldIgnoreConsoleError.js
new file mode 100644
index 00000000..1c722ba1
--- /dev/null
+++ b/tests/shouldIgnoreConsoleError.js
@@ -0,0 +1,52 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/shouldIgnoreConsoleError.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+
+module.exports = function shouldIgnoreConsoleError(format) {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof format === 'string') {
+ if (format.indexOf('Error: Uncaught [') === 0) {
+ // This looks like an uncaught error from invokeGuardedCallback() wrapper
+ // in development that is reported by jsdom. Ignore because it's noisy.
+ return true
+ }
+ if (format.indexOf('The above error occurred') === 0) {
+ // This looks like an error addendum from ReactFiberErrorLogger.
+ // Ignore it too.
+ return true
+ }
+ if (
+ format.startsWith(
+ 'Warning: `ReactDOMTestUtils.act` is deprecated in favor of `React.act`.',
+ )
+ ) {
+ // This is a React bug in 18.3.0.
+ // Versions with `ReactDOMTestUtils.ac` being deprecated, should have `React.act`
+ return true
+ }
+ }
+ }
+ // Looks legit
+ return false
+}
diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js
new file mode 100644
index 00000000..3005125e
--- /dev/null
+++ b/tests/toWarnDev.js
@@ -0,0 +1,303 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/matchers/toWarnDev.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable no-unsafe-finally */
+/* eslint-disable no-negated-condition */
+/* eslint-disable no-invalid-this */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+/* eslint-disable complexity */
+const util = require('util')
+const jestDiff = require('jest-diff').diff
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+function normalizeCodeLocInfo(str) {
+ if (typeof str !== 'string') {
+ return str
+ }
+ // This special case exists only for the special source location in
+ // ReactElementValidator. That will go away if we remove source locations.
+ str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **')
+ // V8 format:
+ // at Component (/path/filename.js:123:45)
+ // React format:
+ // in Component (at filename.js:123)
+ // eslint-disable-next-line prefer-arrow-callback
+ return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
+ return '\n in ' + name + ' (at **)'
+ })
+}
+
+const createMatcherFor = (consoleMethod, matcherName) =>
+ function matcher(callback, expectedMessages, options = {}) {
+ if (process.env.NODE_ENV !== 'production') {
+ // Warn about incorrect usage of matcher.
+ if (typeof expectedMessages === 'string') {
+ expectedMessages = [expectedMessages]
+ } else if (!Array.isArray(expectedMessages)) {
+ throw Error(
+ `${matcherName}() requires a parameter of type string or an array of strings ` +
+ `but was given ${typeof expectedMessages}.`,
+ )
+ }
+ if (
+ options != null &&
+ (typeof options !== 'object' || Array.isArray(options))
+ ) {
+ throw new Error(
+ `${matcherName}() second argument, when present, should be an object. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+ if (arguments.length > 3) {
+ // `matcher` comes from Jest, so it's more than 2 in practice
+ throw new Error(
+ `${matcherName}() received more than two arguments. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+
+ const withoutStack = options.withoutStack
+ const logAllErrors = options.logAllErrors
+ const warningsWithoutComponentStack = []
+ const warningsWithComponentStack = []
+ const unexpectedWarnings = []
+
+ let lastWarningWithMismatchingFormat = null
+ let lastWarningWithExtraComponentStack = null
+
+ // Catch errors thrown by the callback,
+ // But only rethrow them if all test expectations have been satisfied.
+ // Otherwise an Error in the callback can mask a failed expectation,
+ // and result in a test that passes when it shouldn't.
+ let caughtError
+
+ const isLikelyAComponentStack = message =>
+ typeof message === 'string' &&
+ (message.includes('\n in ') || message.includes('\n at '))
+
+ const consoleSpy = (format, ...args) => {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (
+ !logAllErrors &&
+ consoleMethod === 'error' &&
+ shouldIgnoreConsoleError(format, args)
+ ) {
+ return
+ }
+
+ const message = util.format(format, ...args)
+ const normalizedMessage = normalizeCodeLocInfo(message)
+
+ // Remember if the number of %s interpolations
+ // doesn't match the number of arguments.
+ // We'll fail the test if it happens.
+ let argIndex = 0
+ String(format).replace(/%s/g, () => argIndex++)
+ if (argIndex !== args.length) {
+ lastWarningWithMismatchingFormat = {
+ format,
+ args,
+ expectedArgCount: argIndex,
+ }
+ }
+
+ // Protect against accidentally passing a component stack
+ // to warning() which already injects the component stack.
+ if (
+ args.length >= 2 &&
+ isLikelyAComponentStack(args[args.length - 1]) &&
+ isLikelyAComponentStack(args[args.length - 2])
+ ) {
+ lastWarningWithExtraComponentStack = {
+ format,
+ }
+ }
+
+ for (let index = 0; index < expectedMessages.length; index++) {
+ const expectedMessage = expectedMessages[index]
+ if (
+ normalizedMessage === expectedMessage ||
+ normalizedMessage.includes(expectedMessage)
+ ) {
+ if (isLikelyAComponentStack(normalizedMessage)) {
+ warningsWithComponentStack.push(normalizedMessage)
+ } else {
+ warningsWithoutComponentStack.push(normalizedMessage)
+ }
+ expectedMessages.splice(index, 1)
+ return
+ }
+ }
+
+ let errorMessage
+ if (expectedMessages.length === 0) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ this.utils.printReceived(normalizedMessage)
+ } else if (expectedMessages.length === 1) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages[0], normalizedMessage)
+ } else {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages, [normalizedMessage])
+ }
+
+ // Record the call stack for unexpected warnings.
+ // We don't throw an Error here though,
+ // Because it might be suppressed by ReactFiberScheduler.
+ unexpectedWarnings.push(new Error(errorMessage))
+ }
+
+ // TODO Decide whether we need to support nested toWarn* expectations.
+ // If we don't need it, add a check here to see if this is already our spy,
+ // And throw an error.
+ const originalMethod = console[consoleMethod]
+
+ // Avoid using Jest's built-in spy since it can't be removed.
+ console[consoleMethod] = consoleSpy
+
+ try {
+ callback()
+ } catch (error) {
+ caughtError = error
+ } finally {
+ // Restore the unspied method so that unexpected errors fail tests.
+ console[consoleMethod] = originalMethod
+
+ // Any unexpected Errors thrown by the callback should fail the test.
+ // This should take precedence since unexpected errors could block warnings.
+ if (caughtError) {
+ throw caughtError
+ }
+
+ // Any unexpected warnings should be treated as a failure.
+ if (unexpectedWarnings.length > 0) {
+ return {
+ message: () => unexpectedWarnings[0].stack,
+ pass: false,
+ }
+ }
+
+ // Any remaining messages indicate a failed expectations.
+ if (expectedMessages.length > 0) {
+ return {
+ message: () =>
+ `Expected warning was not recorded:\n ${this.utils.printReceived(
+ expectedMessages[0],
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (typeof withoutStack === 'number') {
+ // We're expecting a particular number of warnings without stacks.
+ if (withoutStack !== warningsWithoutComponentStack.length) {
+ return {
+ message: () =>
+ `Expected ${withoutStack} warnings without a component stack but received ${warningsWithoutComponentStack.length}:\n` +
+ warningsWithoutComponentStack.map(warning =>
+ this.utils.printReceived(warning),
+ ),
+ pass: false,
+ }
+ }
+ } else if (withoutStack === true) {
+ // We're expecting that all warnings won't have the stack.
+ // If some warnings have it, it's an error.
+ if (warningsWithComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly includes a component stack:\n ${this.utils.printReceived(
+ warningsWithComponentStack[0],
+ )}\nIf this warning intentionally includes the component stack, remove ` +
+ `{withoutStack: true} from the ${matcherName}() call. If you have a mix of ` +
+ `warnings with and without stack in one ${matcherName}() call, pass ` +
+ `{withoutStack: N} where N is the number of warnings without stacks.`,
+ pass: false,
+ }
+ }
+ } else if (withoutStack === false || withoutStack === undefined) {
+ // We're expecting that all warnings *do* have the stack (default).
+ // If some warnings don't have it, it's an error.
+ if (warningsWithoutComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly does not include a component stack:\n ${this.utils.printReceived(
+ warningsWithoutComponentStack[0],
+ )}\nIf this warning intentionally omits the component stack, add ` +
+ `{withoutStack: true} to the ${matcherName} call.`,
+ pass: false,
+ }
+ }
+ } else {
+ throw Error(
+ `The second argument for ${matcherName}(), when specified, must be an object. It may have a ` +
+ `property called "withoutStack" whose value may be undefined, boolean, or a number. ` +
+ `Instead received ${typeof withoutStack}.`,
+ )
+ }
+
+ if (lastWarningWithMismatchingFormat !== null) {
+ return {
+ message: () =>
+ `Received ${
+ lastWarningWithMismatchingFormat.args.length
+ } arguments for a message with ${
+ lastWarningWithMismatchingFormat.expectedArgCount
+ } placeholders:\n ${this.utils.printReceived(
+ lastWarningWithMismatchingFormat.format,
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (lastWarningWithExtraComponentStack !== null) {
+ return {
+ message: () =>
+ `Received more than one component stack for a warning:\n ${this.utils.printReceived(
+ lastWarningWithExtraComponentStack.format,
+ )}\nDid you accidentally pass a stack to warning() as the last argument? ` +
+ `Don't forget warning() already injects the component stack automatically.`,
+ pass: false,
+ }
+ }
+
+ return {pass: true}
+ }
+ } else {
+ // Any uncaught errors or warnings should fail tests in production mode.
+ callback()
+
+ return {pass: true}
+ }
+ }
+
+module.exports = {
+ toWarnDev: createMatcherFor('warn', 'toWarnDev'),
+ toErrorDev: createMatcherFor('error', 'toErrorDev'),
+}
diff --git a/types/index.d.ts b/types/index.d.ts
index 09824704..bdd60567 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,33 +1,166 @@
// TypeScript Version: 3.8
-
-import {OptionsReceived as PrettyFormatOptions} from 'pretty-format'
-import {queries, Queries, BoundFunction} from '@testing-library/dom'
-import {act as reactAct} from 'react-dom/test-utils'
+import * as ReactDOMClient from 'react-dom/client'
+import {
+ queries,
+ Queries,
+ BoundFunction,
+ prettyFormat,
+ Config as ConfigDTL,
+} from '@testing-library/dom'
+import {act as reactDeprecatedAct} from 'react-dom/test-utils'
+//@ts-ignore
+import {act as reactAct} from 'react'
export * from '@testing-library/dom'
-export type RenderResult = {
- container: HTMLElement
- baseElement: HTMLElement
+export interface Config extends ConfigDTL {
+ reactStrictMode: boolean
+}
+
+export interface ConfigFn {
+ (existingConfig: Config): Partial
+}
+
+export function configure(configDelta: ConfigFn | Partial): void
+
+export function getConfig(): Config
+
+export type RenderResult<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> = {
+ container: Container
+ baseElement: BaseElement
debug: (
baseElement?:
- | HTMLElement
- | DocumentFragment
- | Array,
- maxLength?: number,
- options?: PrettyFormatOptions,
+ | RendererableContainer
+ | HydrateableContainer
+ | Array
+ | undefined,
+ maxLength?: number | undefined,
+ options?: prettyFormat.OptionsReceived | undefined,
) => void
- rerender: (ui: React.ReactElement) => void
- unmount: () => boolean
+ rerender: (ui: React.ReactNode) => void
+ unmount: () => void
asFragment: () => DocumentFragment
} & {[P in keyof Q]: BoundFunction}
-export interface RenderOptions {
- container?: HTMLElement
- baseElement?: HTMLElement
- hydrate?: boolean
- queries?: Q
- wrapper?: React.ComponentType
+/** @deprecated */
+export type BaseRenderOptions<
+ Q extends Queries,
+ Container extends RendererableContainer | HydrateableContainer,
+ BaseElement extends RendererableContainer | HydrateableContainer,
+> = RenderOptions
+
+type RendererableContainer = ReactDOMClient.Container
+type HydrateableContainer = Parameters[0]
+/** @deprecated */
+export interface ClientRenderOptions<
+ Q extends Queries,
+ Container extends RendererableContainer,
+ BaseElement extends RendererableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate?: false | undefined
+}
+/** @deprecated */
+export interface HydrateOptions<
+ Q extends Queries,
+ Container extends HydrateableContainer,
+ BaseElement extends HydrateableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate: true
+}
+
+export interface RenderOptions<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> {
+ /**
+ * By default, React Testing Library will create a div and append that div to the document.body. Your React component will be rendered in the created div. If you provide your own HTMLElement container via this option,
+ * it will not be appended to the document.body automatically.
+ *
+ * For example: If you are unit testing a `` element, it cannot be a child of a div. In this case, you can
+ * specify a table as the render container.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#container
+ */
+ container?: Container | undefined
+ /**
+ * Defaults to the container if the container is specified. Otherwise `document.body` is used for the default. This is used as
+ * the base element for the queries as well as what is printed when you use `debug()`.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#baseelement
+ */
+ baseElement?: BaseElement | undefined
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate?: boolean | undefined
+ /**
+ * Only works if used with React 18.
+ * Set to `true` if you want to force synchronous `ReactDOM.render`.
+ * Otherwise `render` will default to concurrent React if available.
+ */
+ legacyRoot?: boolean | undefined
+ /**
+ * Only supported in React 19.
+ * Callback called when React catches an error in an Error Boundary.
+ * Called with the error caught by the Error Boundary, and an `errorInfo` object containing the `componentStack`.
+ *
+ * @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
+ */
+ onCaughtError?: ReactDOMClient.RootOptions extends {
+ onCaughtError: infer OnCaughtError
+ }
+ ? OnCaughtError
+ : never
+ /**
+ * Callback called when React automatically recovers from errors.
+ * Called with an error React throws, and an `errorInfo` object containing the `componentStack`.
+ * Some recoverable errors may include the original error cause as `error.cause`.
+ *
+ * @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
+ */
+ onRecoverableError?: ReactDOMClient.RootOptions['onRecoverableError']
+ /**
+ * Not supported at the moment
+ */
+ onUncaughtError?: never
+ /**
+ * Queries to bind. Overrides the default set from DOM Testing Library unless merged.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#queries
+ */
+ queries?: Q | undefined
+ /**
+ * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating
+ * reusable custom render functions for common data providers. See setup for examples.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#wrapper
+ */
+ wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> | undefined
+ /**
+ * When enabled, is rendered around the inner element.
+ * If defined, overrides the value of `reactStrictMode` set in `configure`.
+ */
+ reactStrictMode?: boolean
}
type Omit = Pick>
@@ -35,25 +168,120 @@ type Omit = Pick>
/**
* Render into a container which is appended to document.body. It should be used with cleanup.
*/
+export function render<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+>(
+ ui: React.ReactNode,
+ options: RenderOptions,
+): RenderResult
export function render(
- ui: React.ReactElement,
- options?: Omit,
+ ui: React.ReactNode,
+ options?: Omit | undefined,
): RenderResult
-export function render(
- ui: React.ReactElement,
- options: RenderOptions,
-): RenderResult
+
+export interface RenderHookResult {
+ /**
+ * Triggers a re-render. The props will be passed to your renderHook callback.
+ */
+ rerender: (props?: Props) => void
+ /**
+ * This is a stable reference to the latest value returned by your renderHook
+ * callback
+ */
+ result: {
+ /**
+ * The value returned by your renderHook callback
+ */
+ current: Result
+ }
+ /**
+ * Unmounts the test component. This is useful for when you need to test
+ * any cleanup your useEffects have.
+ */
+ unmount: () => void
+}
+
+/** @deprecated */
+export type BaseRenderHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends RendererableContainer | HydrateableContainer,
+ BaseElement extends Element | DocumentFragment,
+> = RenderHookOptions
+
+/** @deprecated */
+export interface ClientRenderHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends Element | DocumentFragment,
+ BaseElement extends Element | DocumentFragment = Container,
+> extends BaseRenderHookOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate?: false | undefined
+}
+
+/** @deprecated */
+export interface HydrateHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends Element | DocumentFragment,
+ BaseElement extends Element | DocumentFragment = Container,
+> extends BaseRenderHookOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate: true
+}
+
+export interface RenderHookOptions<
+ Props,
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * The argument passed to the renderHook callback. Can be useful if you plan
+ * to use the rerender utility to change the values passed to your hook.
+ */
+ initialProps?: Props | undefined
+}
+
+/**
+ * Allows you to render a hook within a test React component without having to
+ * create that component yourself.
+ */
+export function renderHook<
+ Result,
+ Props,
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+>(
+ render: (initialProps: Props) => Result,
+ options?: RenderHookOptions | undefined,
+): RenderHookResult
/**
* Unmounts React trees that were mounted with render.
*/
-export function cleanup(): Promise
+export function cleanup(): void
/**
- * Simply calls ReactDOMTestUtils.act(cb)
+ * Simply calls React.act(cb)
* If that's not available (older version of react) then it
- * simply calls the given callback immediately
+ * simply calls the deprecated version which is ReactTestUtils.act(cb)
*/
-export const act: typeof reactAct extends undefined
- ? (callback: () => void) => void
+// IfAny from https://stackoverflow.com/a/61626123/3406963
+export const act: 0 extends 1 & typeof reactAct
+ ? typeof reactDeprecatedAct
: typeof reactAct
diff --git a/types/test.tsx b/types/test.tsx
index c273feb0..825d5699 100644
--- a/types/test.tsx
+++ b/types/test.tsx
@@ -1,53 +1,92 @@
import * as React from 'react'
-import {render, fireEvent, screen, waitFor} from '@testing-library/react'
-import * as pure from '@testing-library/react/pure'
+import {render, fireEvent, screen, waitFor, renderHook} from '.'
+import * as pure from './pure'
-async function testRender() {
- const page = render()
+export async function testRender() {
+ const view = render()
// single queries
- page.getByText('foo')
- page.queryByText('foo')
- await page.findByText('foo')
+ view.getByText('foo')
+ view.queryByText('foo')
+ await view.findByText('foo')
// multiple queries
- page.getAllByText('bar')
- page.queryAllByText('bar')
- await page.findAllByText('bar')
+ view.getAllByText('bar')
+ view.queryAllByText('bar')
+ await view.findAllByText('bar')
// helpers
- const {container, rerender, debug} = page
+ const {container, rerender, debug} = view
+ expectType(container)
+ return {container, rerender, debug}
}
-async function testPureRender() {
- const page = pure.render()
+export async function testPureRender() {
+ const view = pure.render()
// single queries
- page.getByText('foo')
- page.queryByText('foo')
- await page.findByText('foo')
+ view.getByText('foo')
+ view.queryByText('foo')
+ await view.findByText('foo')
// multiple queries
- page.getAllByText('bar')
- page.queryAllByText('bar')
- await page.findAllByText('bar')
+ view.getAllByText('bar')
+ view.queryAllByText('bar')
+ await view.findAllByText('bar')
// helpers
- const {container, rerender, debug} = page
+ const {container, rerender, debug} = view
+ expectType(container)
+ return {container, rerender, debug}
}
-async function testRenderOptions() {
+export function testRenderOptions() {
const container = document.createElement('div')
const options = {container}
- render(, options)
+ const {container: returnedContainer} = render(, options)
+ expectType(returnedContainer)
+
+ render(, {wrapper: () => null})
+}
+
+export function testSVGRenderOptions() {
+ const container = document.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'svg',
+ )
+ const options = {container}
+ const {container: returnedContainer} = render( , options)
+ expectType(returnedContainer)
}
-async function testFireEvent() {
+export function testFireEvent() {
const {container} = render()
fireEvent.click(container)
}
-async function testDebug() {
+export function testConfigure() {
+ // test for DTL's config
+ pure.configure({testIdAttribute: 'foobar'})
+ pure.configure(existingConfig => ({
+ testIdAttribute: `modified-${existingConfig.testIdAttribute}`,
+ }))
+
+ // test for RTL's config
+ pure.configure({reactStrictMode: true})
+ pure.configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+}
+
+export function testGetConfig() {
+ // test for DTL's config
+ pure.getConfig().testIdAttribute
+
+ // test for RTL's config
+ pure.getConfig().reactStrictMode
+}
+
+export function testDebug() {
const {debug, getAllByTestId} = render(
<>
Hello World
@@ -57,14 +96,222 @@ async function testDebug() {
debug(getAllByTestId('testid'))
}
-async function testScreen() {
+export async function testScreen() {
render()
- screen.findByRole('button')
+ await screen.findByRole('button')
}
-async function testWaitFor() {
+export async function testWaitFor() {
const {container} = render()
fireEvent.click(container)
await waitFor(() => {})
}
+
+export function testQueries() {
+ const {getByLabelText} = render(
+ ,
+ )
+ expectType>(
+ getByLabelText('Username'),
+ )
+
+ const container = document.createElement('div')
+ const options = {container}
+ const {getByText} = render(Hello World, options)
+ expectType>(
+ getByText('Hello World'),
+ )
+}
+
+export function wrappedRender(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ const Wrapper = ({
+ children,
+ }: {
+ children: React.ReactNode
+ }): React.JSX.Element => {
+ return {children}
+ }
+
+ return pure.render(ui, {
+ wrapper: Wrapper,
+ // testing exactOptionalPropertyTypes comaptibility
+ hydrate: options?.hydrate,
+ ...options,
+ })
+}
+
+export function wrappedRenderB(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ const Wrapper: React.FunctionComponent<{children?: React.ReactNode}> = ({
+ children,
+ }) => {
+ return {children}
+ }
+
+ return pure.render(ui, {wrapper: Wrapper, ...options})
+}
+
+export function wrappedRenderC(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.render(ui, {wrapper: AppWrapperProps, ...options})
+}
+
+export function wrappedRenderHook(
+ hook: () => unknown,
+ options?: pure.RenderHookOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.renderHook(hook, {...options})
+}
+
+export function testBaseElement() {
+ const {baseElement: baseDefaultElement} = render()
+ expectType(baseDefaultElement)
+
+ const container = document.createElement('input')
+ const {baseElement: baseElementFromContainer} = render(, {container})
+ expectType(
+ baseElementFromContainer,
+ )
+
+ const baseElementOption = document.createElement('input')
+ const {baseElement: baseElementFromOption} = render(, {
+ baseElement: baseElementOption,
+ })
+ expectType(
+ baseElementFromOption,
+ )
+}
+
+export function testRenderHook() {
+ const {result, rerender, unmount} = renderHook(() => React.useState(2)[0])
+
+ expectType(result.current)
+
+ rerender()
+
+ unmount()
+
+ renderHook(() => null, {wrapper: () => null})
+}
+
+export function testRenderHookProps() {
+ const {result, rerender, unmount} = renderHook(
+ ({defaultValue}) => React.useState(defaultValue)[0],
+ {initialProps: {defaultValue: 2}},
+ )
+
+ expectType(result.current)
+
+ rerender()
+
+ unmount()
+}
+
+export function testContainer() {
+ render('a', {container: document.createElement('div')})
+ render('a', {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ render('a', {container: document})
+ render('a', {container: document.createElement('div'), hydrate: true})
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ render('a', {container: document.createDocumentFragment(), hydrate: true})
+ render('a', {container: document, hydrate: true})
+
+ renderHook(() => null, {container: document.createElement('div')})
+ renderHook(() => null, {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ renderHook(() => null, {container: document})
+ renderHook(() => null, {
+ container: document.createElement('div'),
+ hydrate: true,
+ })
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ renderHook(() => null, {
+ container: document.createDocumentFragment(),
+ hydrate: true,
+ })
+ renderHook(() => null, {container: document, hydrate: true})
+}
+
+export function testErrorHandlers() {
+ // React 19 types are not used in tests. Verify manually if this works with `"@types/react": "npm:types-react@rc"`
+ render(null, {
+ // Should work with React 19 types
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ onCaughtError: () => {},
+ })
+ render(null, {
+ // Should never work as it's not supported yet.
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ onUncaughtError: () => {},
+ })
+ render(null, {
+ onRecoverableError: (error, errorInfo) => {
+ console.error(error)
+ console.log(errorInfo.componentStack)
+ },
+ })
+}
+
+/*
+eslint
+ testing-library/prefer-explicit-assert: "off",
+ testing-library/no-wait-for-empty-callback: "off",
+ testing-library/prefer-screen-queries: "off"
+*/
+
+// https://stackoverflow.com/questions/53807517/how-to-test-if-two-types-are-exactly-the-same
+type IfEquals = (() => G extends T
+ ? 1
+ : 2) extends () => G extends U ? 1 : 2
+ ? Yes
+ : No
+
+/**
+ * Issues a type error if `Expected` is not identical to `Actual`.
+ *
+ * `Expected` should be declared when invoking `expectType`.
+ * `Actual` should almost always we be a `typeof value` statement.
+ *
+ * Source: https://github.com/mui-org/material-ui/blob/6221876a4b468a3330ffaafa8472de7613933b87/packages/material-ui-types/index.d.ts#L73-L84
+ *
+ * @example `expectType(value)`
+ * TypeScript issues a type error since `value is not assignable to never`.
+ * This means `typeof value` is not identical to `number | string`
+ * @param actual
+ */
+declare function expectType(
+ actual: IfEquals,
+): void
diff --git a/types/tsconfig.json b/types/tsconfig.json
index a6ba6c3d..bad26af7 100644
--- a/types/tsconfig.json
+++ b/types/tsconfig.json
@@ -1,19 +1,8 @@
-// this additional tsconfig is required by dtslint
-// see: https://github.com/Microsoft/dtslint#typestsconfigjson
{
+ "extends": "../node_modules/kcd-scripts/shared-tsconfig.json",
"compilerOptions": {
- "module": "commonjs",
- "lib": ["es6", "dom"],
- "jsx": "react",
- "noImplicitAny": true,
- "noImplicitThis": true,
- "strictNullChecks": true,
- "strictFunctionTypes": true,
- "noEmit": true,
- "baseUrl": ".",
- "paths": {
- "@testing-library/react": ["."],
- "@testing-library/react/pure": ["."]
- }
- }
+ "exactOptionalPropertyTypes": true,
+ "skipLibCheck": false
+ },
+ "include": ["."]
}
diff --git a/types/tslint.json b/types/tslint.json
deleted file mode 100644
index cb0fce9f..00000000
--- a/types/tslint.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": ["dtslint/dtslint.json"],
- "rules": {
- "no-useless-files": false,
- "no-relative-import-in-test": false,
- "semicolon": false,
- "whitespace": false
- }
-}