├── .eslintignore
├── .eslintrc.cjs
├── .git-blame-ignore-revs
├── .github
└── workflows
│ ├── ci.yml
│ ├── lint-pr.yml
│ └── pkg-pr-new-publish.yml
├── .gitignore
├── .prettierrc.cjs
├── .yarn
└── releases
│ └── yarn-4.5.0.cjs
├── .yarnrc.yml
├── LICENSE
├── README.md
├── jest.config.cjs
├── other
└── apollo-wordmark.svg
├── package.json
├── src
├── __testHelpers__
│ ├── getCleanedErrorMessage.ts
│ └── useShim.js
├── __tests__
│ └── renderHookToSnapshotStream.test.tsx
├── assertable.ts
├── disableActEnvironment.ts
├── expect
│ ├── __tests__
│ │ └── renderStreamMatchers.test.tsx
│ ├── index.ts
│ └── renderStreamMatchers.ts
├── index.ts
├── pure.ts
├── renderHookToSnapshotStream.tsx
├── renderStream
│ ├── Render.tsx
│ ├── __tests__
│ │ ├── createRenderStream.test.tsx
│ │ └── useTrackRenders.test.tsx
│ ├── context.tsx
│ ├── createRenderStream.tsx
│ ├── syncQueries.ts
│ └── useTrackRenders.ts
└── renderWithoutAct.tsx
├── tests
├── polyfill.js
└── setup-env.js
├── tsconfig.json
├── tsup.config.ts
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | tsup.config.ts
2 | dist/
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: 'kentcdodds',
3 | rules: {
4 | '@typescript-eslint/no-explicit-any': 'off',
5 | '@typescript-eslint/no-empty-interface': 'off',
6 | '@typescript-eslint/no-non-null-assertion': 'off',
7 | '@typescript-eslint/unified-signatures': 'off',
8 | '@typescript-eslint/no-unused-vars': [
9 | 'error',
10 | {
11 | args: 'after-used',
12 | argsIgnorePattern: '^_',
13 | ignoreRestSiblings: true,
14 | varsIgnorePattern: '^_',
15 | },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # format with kcd-scripts
2 | 325d59e3cd0bf4c7ab738381e1bb49aef3bc7363
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - '**'
8 | tags:
9 | - '!**'
10 |
11 | jobs:
12 | ci:
13 | name: CI
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout repo
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Node.js 22.x
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: 22.x
23 | cache: yarn
24 |
25 | - name: Install dependencies
26 | run: yarn install
27 |
28 | - name: Run tests, lint and verify package integrity
29 | run: yarn run validate
30 |
31 | release:
32 | permissions:
33 | id-token: write # required for provenance
34 | actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action)
35 | contents: write # to create release tags (cycjimmy/semantic-release-action)
36 | issues: write # to post release that resolves an issue (cycjimmy/semantic-release-action)
37 |
38 | needs: ci
39 | runs-on: ubuntu-latest
40 | if:
41 | ${{ github.repository ==
42 | 'testing-library/react-render-stream-testing-library' && github.event_name
43 | == 'push' }}
44 | steps:
45 | - name: Checkout repo
46 | uses: actions/checkout@v4
47 |
48 | - name: Setup Node.js 22.x
49 | uses: actions/setup-node@v4
50 | with:
51 | node-version: 22.x
52 | cache: yarn
53 |
54 | - name: Install dependencies
55 | run: yarn install
56 |
57 | - name: 🏗 Run build script
58 | run: yarn build
59 |
60 | - name: 🚀 Release
61 | uses: cycjimmy/semantic-release-action@v4
62 | with:
63 | branches: |
64 | [
65 | '+([0-9])?(.{+([0-9]),x}).x',
66 | 'main',
67 | 'next',
68 | 'next-major',
69 | {name: 'beta', prerelease: true},
70 | {name: 'alpha', prerelease: true}
71 | ]
72 | env:
73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
75 |
--------------------------------------------------------------------------------
/.github/workflows/lint-pr.yml:
--------------------------------------------------------------------------------
1 | name: 'Lint PR'
2 |
3 | on:
4 | pull_request_target:
5 | types:
6 | - opened
7 | - edited
8 | - synchronize
9 | - reopened
10 |
11 | permissions:
12 | pull-requests: read
13 |
14 | jobs:
15 | main:
16 | name: Validate PR title
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: amannn/action-semantic-pull-request@v5
20 | env:
21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22 | with:
23 | requireScope: false
24 | # If the PR only contains a single commit, the action will validate that
25 | # it matches the configured pattern.
26 | validateSingleCommit: true
27 | # Related to `validateSingleCommit` you can opt-in to validate that the PR
28 | # title matches a single commit to avoid confusion.
29 | validateSingleCommitMatchesPrTitle: true
30 |
--------------------------------------------------------------------------------
/.github/workflows/pkg-pr-new-publish.yml:
--------------------------------------------------------------------------------
1 | name: pkg-pr-new Publish
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - '**'
8 | tags:
9 | - '!**'
10 |
11 | jobs:
12 | prerelease:
13 | name: pkg-pr-new Publish
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout repo
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Node.js 20.x
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: 20.x
23 | cache: yarn
24 |
25 | - name: Install dependencies
26 | run: yarn install
27 |
28 | - name: Build and publish to pkg.pr.new
29 | run: yarn run pkg-pr-new-publish
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .yarn/*
4 | !.yarn/patches
5 | !.yarn/plugins
6 | !.yarn/releases
7 | !.yarn/sdks
8 | !.yarn/versions
9 | *.tsbuildinfo
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require("kcd-scripts/prettier.js");
2 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | yarnPath: .yarn/releases/yarn-4.5.0.cjs
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @testing-library/react-render-stream
2 |
3 | ## What is this library?
4 |
5 | This library allows you to make committed-render-to-committed-render assertions
6 | on your React components and hooks. This is usually not necessary, but can be
7 | highly beneficial when testing hot code paths.
8 |
9 | ## Who is this library for?
10 |
11 | This library is intended to test libraries or library-like code. It requires you
12 | to write additional components so you can test how your components interact with
13 | other components in specific scenarios.
14 |
15 | As such, it is not intended to be used for end-to-end testing of your
16 | application.
17 |
18 | ## Brought to you by Apollo
19 |
20 |
21 |
22 |
23 | This library originally was part of the Apollo Client test suite and is
24 | maintained by the Apollo Client team.
25 |
26 | ### Usage examples:
27 |
28 | #### `createRenderStream` with DOM snapshots
29 |
30 | If used with `snapshotDOM`, RSTL will create a snapshot of your DOM after every
31 | render, and you can iterate through all the intermediate states of your DOM at
32 | your own pace, independenly of how fast these renders actually happened.
33 |
34 | ```jsx
35 | test('iterate through renders with DOM snapshots', async () => {
36 | const {takeRender, render} = createRenderStream({
37 | snapshotDOM: true,
38 | })
39 | const utils = await render()
40 | const incrementButton = utils.getByText('Increment')
41 | await userEvent.click(incrementButton)
42 | await userEvent.click(incrementButton)
43 | {
44 | const {withinDOM} = await takeRender()
45 | const input = withinDOM().getByLabelText('Value')
46 | expect(input.value).toBe('0')
47 | }
48 | {
49 | const {withinDOM} = await takeRender()
50 | const input = withinDOM().getByLabelText('Value')
51 | expect(input.value).toBe('1')
52 | }
53 | {
54 | const {withinDOM} = await takeRender()
55 | const input = withinDOM().getByLabelText('Value')
56 | expect(input.value).toBe('2')
57 | }
58 | })
59 | ```
60 |
61 | ### `renderHookToSnapshotStream`
62 |
63 | Usage is very similar to RTL's `renderHook`, but you get a `snapshotStream`
64 | object back that you can iterate with `takeSnapshot` calls.
65 |
66 | ```jsx
67 | test('`useQuery` with `skip`', async () => {
68 | const {takeSnapshot, rerender} = await renderHookToSnapshotStream(
69 | ({skip}) => useQuery(query, {skip}),
70 | {
71 | wrapper: ({children}) => {children},
72 | },
73 | )
74 |
75 | {
76 | const result = await takeSnapshot()
77 | expect(result.loading).toBe(true)
78 | expect(result.data).toBe(undefined)
79 | }
80 | {
81 | const result = await takeSnapshot()
82 | expect(result.loading).toBe(false)
83 | expect(result.data).toEqual({hello: 'world 1'})
84 | }
85 |
86 | await rerender({skip: true})
87 | {
88 | const snapshot = await takeSnapshot()
89 | expect(snapshot.loading).toBe(false)
90 | expect(snapshot.data).toEqual(undefined)
91 | }
92 | })
93 | ```
94 |
95 | ### Tracking which components rerender with `useTrackRenders`
96 |
97 | You can track if a component was rerendered during a specific render by calling
98 | `useTrackRenders` within it.
99 |
100 | ```jsx
101 | test('`useTrackRenders` with suspense', async () => {
102 | function ErrorComponent() {
103 | useTrackRenders()
104 | // return ...
105 | }
106 | function DataComponent() {
107 | useTrackRenders()
108 | const data = useSuspenseQuery(someQuery)
109 | // return ...
110 | }
111 | function LoadingComponent() {
112 | useTrackRenders()
113 | // return ...
114 | }
115 | function App() {
116 | useTrackRenders()
117 | return (
118 |
119 | }>
120 |
121 |
122 |
123 | )
124 | }
125 |
126 | const {takeRender, render} = createRenderStream()
127 | await render()
128 | {
129 | const {renderedComponents} = await takeRender()
130 | expect(renderedComponents).toEqual([App, LoadingComponent])
131 | }
132 | {
133 | const {renderedComponents} = await takeRender()
134 | expect(renderedComponents).toEqual([DataComponent])
135 | }
136 | })
137 | ```
138 |
139 | > [!NOTE]
140 | >
141 | > The order of components in `renderedComponents` is the order of execution of
142 | > `useLayoutEffect`. Keep in mind that this might not be the order you would
143 | > expect.
144 |
145 | ### taking custom snapshots inside of helper Components with `replaceSnapshot`
146 |
147 | If you need to, you can also take custom snapshots of data in each render.
148 |
149 | ```tsx
150 | test('custom snapshots with `replaceSnapshot`', async () => {
151 | function Counter() {
152 | const [value, setValue] = React.useState(0)
153 | replaceSnapshot({value})
154 | // return ...
155 | }
156 |
157 | const {takeRender, replaceSnapshot, render} = createRenderStream<{
158 | value: number
159 | }>()
160 | const utils = await render()
161 | const incrementButton = utils.getByText('Increment')
162 | await userEvent.click(incrementButton)
163 | {
164 | const {snapshot} = await takeRender()
165 | expect(snapshot).toEqual({value: 0})
166 | }
167 | {
168 | const {snapshot} = await takeRender()
169 | expect(snapshot).toEqual({value: 1})
170 | }
171 | })
172 | ```
173 |
174 | > [!TIP]
175 | >
176 | > `replaceSnapshot` can also be called with a callback that gives you access to
177 | > the last snapshot value.
178 |
179 | > [!TIP]
180 | >
181 | > You can also use `mergeSnapshot`, which shallowly merges the last snapshot
182 | > with the new one instead of replacing it.
183 |
184 | ### Making assertions directly after a render with `onRender`
185 |
186 | ```tsx
187 | test('assertions in `onRender`', async () => {
188 | function Counter() {
189 | const [value, setValue] = React.useState(0)
190 | replaceSnapshot({value})
191 | return (
192 | setValue(v => v + 1)} />
193 | )
194 | }
195 |
196 | const {takeRender, replaceSnapshot, utils} = await renderToRenderStream<{
197 | value: number
198 | }>({
199 | onRender(info) {
200 | // you can use `expect` here
201 | expect(info.count).toBe(info.snapshot.value + 1)
202 | },
203 | })
204 | const incrementButton = utils.getByText('Increment')
205 | await userEvent.click(incrementButton)
206 | await userEvent.click(incrementButton)
207 | await takeRender()
208 | await takeRender()
209 | await takeRender()
210 | })
211 | ```
212 |
213 | > [!NOTE]
214 | >
215 | > `info` contains the
216 | > [base profiling information](https://react.dev/reference/react/Profiler#onrender-parameters)
217 | > passed into `onRender` of React's `Profiler` component, as well as `snapshot`,
218 | > `replaceSnapshot` and `mergeSnapshot`
219 |
220 | ### `expect(...)[.not].toRerender()` and `expect(...)[.not].toRenderExactlyTimes(n)`
221 |
222 | This library adds to matchers to `expect` that can be used like
223 |
224 | ```tsx
225 | test('basic functionality', async () => {
226 | const {takeRender} = await renderToRenderStream()
227 |
228 | await expect(takeRender).toRerender()
229 | await takeRender()
230 |
231 | // trigger a rerender somehow
232 | await expect(takeRender).toRerender()
233 | await takeRender()
234 |
235 | // ensure at the end of a test that no more renders will happen
236 | await expect(takeRender).not.toRerender()
237 | await expect(takeRender).toRenderExactlyTimes(2)
238 | })
239 | ```
240 |
241 | These matchers can be used on multiple different objects:
242 |
243 | ```ts
244 | await expect(takeRender).toRerender()
245 | await expect(renderStream).toRerender()
246 | await expect(takeSnapshot).toRerender()
247 | await expect(snapshotStream).toRerender()
248 | ```
249 |
250 | > [!NOTE]
251 | >
252 | > By default, `.toRerender` and `toRenderExactlyTimes` will wait 100ms for
253 | > renders or to ensure no more renders happens.
254 | >
255 | > You can modify that with the `timeout` option:
256 | >
257 | > ```js
258 | > await expect(takeRender).not.toRerender({timeout: 300})
259 | > ```
260 |
261 | > [!TIP]
262 | >
263 | > If you don't want these matchers not to be automatically installed, you can
264 | > import from `@testing-library/react-render-stream/pure` instead.
265 | > Keep in mind that if you use the `/pure` import, you have to call the
266 | > `cleanup` export manually after each test.
267 |
268 | ## Usage side-by side with `@testing-library/react` or other tools that use `act` or set `IS_REACT_ACT_ENVIRONMENT`
269 |
270 | This library should not be used with `act`, and it will throw an error if
271 | `IS_REACT_ACT_ENVIRONMENT` is `true`.
272 |
273 | React Testing Library sets `IS_REACT_ACT_ENVIRONMENT` to `true` globally, and
274 | wraps some helpers like `userEvent.click` in `act` calls.
275 | To use this library side-by-side with React Testing Library, we ship the
276 | `disableActEnvironment` helper to undo these changes temporarily.
277 |
278 | It returns a `Disposable` and can be used together with the
279 | [`using` keyword](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management)
280 | to automatically clean up once the scope is left:
281 |
282 | ```ts
283 | test('my test', () => {
284 | using _disabledAct = disableActEnvironment()
285 |
286 | // your test code here
287 |
288 | // as soon as this scope is left, the environment will be cleaned up
289 | })
290 | ```
291 |
292 | If you cannot use `using`, you can also manually call the returned `cleanup`
293 | function. We recommend using `finally` to ensure the act environment is cleaned
294 | up if your test fails, otherwise it could leak between tests:
295 |
296 | ```ts
297 | test('my test', () => {
298 | const {cleanup} = disableActEnvironment()
299 |
300 | try {
301 | // your test code here
302 | } finally {
303 | cleanup()
304 | }
305 | })
306 | ```
307 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | const {jest: jestConfig} = require('kcd-scripts/config')
2 |
3 | module.exports = Object.assign(jestConfig, {
4 | resolver: 'ts-jest-resolver',
5 | prettierPath: null,
6 | })
7 |
--------------------------------------------------------------------------------
/other/apollo-wordmark.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@testing-library/react-render-stream",
3 | "version": "0.0.0-semantically-released",
4 | "repository": {
5 | "url": "git+https://github.com/testing-library/react-render-stream-testing-library.git"
6 | },
7 | "author": {
8 | "name": "Lenz Weber-Tronic",
9 | "email": "lenz@apollographql.com"
10 | },
11 | "type": "module",
12 | "license": "MIT",
13 | "exports": {
14 | ".": {
15 | "types": {
16 | "module-sync": "./dist/index.d.ts",
17 | "module": "./dist/index.d.ts",
18 | "import": "./dist/index.d.ts",
19 | "require": "./dist/index.d.cts"
20 | },
21 | "module-sync": "./dist/index.js",
22 | "module": "./dist/index.js",
23 | "import": "./dist/index.js",
24 | "require": "./dist/index.cjs"
25 | },
26 | "./pure": {
27 | "types": {
28 | "module-sync": "./dist/pure.d.ts",
29 | "module": "./dist/pure.d.ts",
30 | "import": "./dist/pure.d.ts",
31 | "require": "./dist/pure.d.cts"
32 | },
33 | "module-sync": "./dist/pure.js",
34 | "module": "./dist/pure.js",
35 | "import": "./dist/pure.js",
36 | "require": "./dist/pure.cjs"
37 | },
38 | "./expect": {
39 | "types": {
40 | "module-sync": "./dist/expect.d.ts",
41 | "module": "./dist/expect.d.ts",
42 | "import": "./dist/expect.d.ts",
43 | "require": "./dist/expect.d.cts"
44 | },
45 | "module-sync": "./dist/expect.js",
46 | "module": "./dist/expect.js",
47 | "import": "./dist/expect.js",
48 | "require": "./dist/expect.cjs"
49 | }
50 | },
51 | "types": "./dist/index.d.ts",
52 | "typesVersions": {
53 | "*": {
54 | "expect": [
55 | "./dist/expect.d.ts"
56 | ],
57 | "pure": [
58 | "./dist/pure.d.ts"
59 | ]
60 | }
61 | },
62 | "files": [
63 | "dist",
64 | "other"
65 | ],
66 | "dependencies": {
67 | "@testing-library/dom": "^10.4.0",
68 | "@testing-library/react": "^16.0.1",
69 | "jsdom": "^25.0.1",
70 | "rehackt": "^0.1.0"
71 | },
72 | "devDependencies": {
73 | "@arethetypeswrong/cli": "^0.16.4",
74 | "@jest/globals": "^29.7.0",
75 | "@testing-library/user-event": "^14.5.2",
76 | "@tsconfig/recommended": "^1.0.7",
77 | "@types/jsdom": "^21.1.7",
78 | "@types/react": "^18",
79 | "@types/react-dom": "^18",
80 | "concurrently": "^9.0.1",
81 | "expect": "^29.7.0",
82 | "kcd-scripts": "^16.0.0",
83 | "pkg-pr-new": "^0.0.29",
84 | "prettier": "^3.3.3",
85 | "publint": "^0.2.11",
86 | "react": "19.0.0",
87 | "react-dom": "19.0.0",
88 | "react-error-boundary": "^4.0.13",
89 | "ts-jest-resolver": "^2.0.1",
90 | "tsup": "^8.3.0",
91 | "typescript": "^5.6.2"
92 | },
93 | "peerDependencies": {
94 | "@jest/globals": "*",
95 | "expect": "*",
96 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc",
97 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc"
98 | },
99 | "scripts": {
100 | "build": "tsup",
101 | "pkg-pr-new-publish": "yarn build && pkg-pr-new publish --no-template",
102 | "prepack": "yarn build",
103 | "format": "kcd-scripts format",
104 | "lint": "kcd-scripts lint --config .eslintrc.cjs",
105 | "test": "kcd-scripts test --config jest.config.cjs",
106 | "pack-and-verify": "attw --pack . && publint",
107 | "typecheck": "kcd-scripts typecheck --build --noEmit",
108 | "validate": "yarn pack-and-verify; CI=true yarn concurrently --group --prefix '[{name}]' --names lint,test,typecheck 'yarn lint' 'yarn test --verbose' 'yarn typecheck'"
109 | },
110 | "packageManager": "yarn@4.5.0",
111 | "resolutions": {
112 | "eslint-config-kentcdodds": "^21.0.0"
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/__testHelpers__/getCleanedErrorMessage.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-control-regex
2 | const consoleColors = /\x1b\[\d+m/g
3 |
4 | export function getExpectErrorMessage(
5 | expectPromise: Promise,
6 | ): Promise {
7 | return expectPromise.then(
8 | () => {
9 | throw new Error('Expected promise to fail, but did not.')
10 | },
11 | e => {
12 | const error = e as Error
13 | return error.message.replace(consoleColors, '')
14 | },
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/__testHelpers__/useShim.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | /* eslint-disable default-case */
4 | /* eslint-disable consistent-return */
5 | function isStatefulPromise(promise) {
6 | return 'status' in promise
7 | }
8 | function wrapPromiseWithState(promise) {
9 | if (isStatefulPromise(promise)) {
10 | return promise
11 | }
12 | const pendingPromise = promise
13 | pendingPromise.status = 'pending'
14 | pendingPromise.then(
15 | value => {
16 | if (pendingPromise.status === 'pending') {
17 | const fulfilledPromise = pendingPromise
18 | fulfilledPromise.status = 'fulfilled'
19 | fulfilledPromise.value = value
20 | }
21 | },
22 | reason => {
23 | if (pendingPromise.status === 'pending') {
24 | const rejectedPromise = pendingPromise
25 | rejectedPromise.status = 'rejected'
26 | rejectedPromise.reason = reason
27 | }
28 | },
29 | )
30 | return promise
31 | }
32 |
33 | /**
34 | * @template T
35 | * @param {Promise} promise
36 | * @returns {T}
37 | */
38 | function _use(promise) {
39 | const statefulPromise = wrapPromiseWithState(promise)
40 | switch (statefulPromise.status) {
41 | case 'pending':
42 | throw statefulPromise
43 | case 'rejected':
44 | throw statefulPromise.reason
45 | case 'fulfilled':
46 | return statefulPromise.value
47 | }
48 | }
49 |
50 | export const __use = /** @type {{use?: typeof _use}} */ (React).use || _use
51 |
--------------------------------------------------------------------------------
/src/__tests__/renderHookToSnapshotStream.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-await-in-loop */
2 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */
3 | import {EventEmitter} from 'node:events'
4 | import {scheduler} from 'node:timers/promises'
5 | import {test, expect} from '@jest/globals'
6 | import {
7 | renderHookToSnapshotStream,
8 | SnapshotStream,
9 | } from '@testing-library/react-render-stream'
10 | import * as React from 'react'
11 |
12 | const testEvents = new EventEmitter<{
13 | rerenderWithValue: [unknown]
14 | }>()
15 |
16 | function useRerenderEvents(initialValue: unknown) {
17 | const lastValueRef = React.useRef(initialValue)
18 | return React.useSyncExternalStore(
19 | onChange => {
20 | const cb = (value: unknown) => {
21 | lastValueRef.current = value
22 | onChange()
23 | }
24 | testEvents.addListener('rerenderWithValue', cb)
25 | return () => {
26 | testEvents.removeListener('rerenderWithValue', cb)
27 | }
28 | },
29 | () => {
30 | return lastValueRef.current
31 | },
32 | )
33 | }
34 |
35 | test('basic functionality', async () => {
36 | const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, {
37 | initialProps: 'initial',
38 | })
39 | testEvents.emit('rerenderWithValue', 'value')
40 | await scheduler.wait(10)
41 | testEvents.emit('rerenderWithValue', 'value2')
42 | {
43 | const snapshot = await takeSnapshot()
44 | expect(snapshot).toBe('initial')
45 | }
46 | {
47 | const snapshot = await takeSnapshot()
48 | expect(snapshot).toBe('value')
49 | }
50 | {
51 | const snapshot = await takeSnapshot()
52 | expect(snapshot).toBe('value2')
53 | }
54 | })
55 |
56 | test.each<[type: string, initialValue: unknown, ...nextValues: unknown[]]>([
57 | ['string', 'initial', 'value', 'value2'],
58 | ['number', 0, 1, 2],
59 | ['functions', () => {}, () => {}, function named() {}],
60 | ['objects', {a: 1}, {a: 2}, {foo: 'bar'}],
61 | ['arrays', [1], [1, 2], [2]],
62 | ['null/undefined', null, undefined, null],
63 | ['undefined/null', undefined, null, undefined],
64 | ])('works with %s', async (_, initialValue, ...nextValues) => {
65 | const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, {
66 | initialProps: initialValue,
67 | })
68 | for (const nextValue of nextValues) {
69 | testEvents.emit('rerenderWithValue', nextValue)
70 | // allow for a render to happen
71 | await Promise.resolve()
72 | }
73 | expect(await takeSnapshot()).toBe(initialValue)
74 | for (const nextValue of nextValues) {
75 | expect(await takeSnapshot()).toBe(nextValue)
76 | }
77 | })
78 |
79 | test.skip('type test: render function without an argument -> no argument required for `rerender`', async () => {
80 | {
81 | // prop type has nothing to infer on - defaults to `void`
82 | const stream = await renderHookToSnapshotStream(() => {})
83 | const _test1: SnapshotStream = stream
84 | // @ts-expect-error should not be assignable
85 | const _test2: SnapshotStream = stream
86 | await stream.rerender()
87 | // @ts-expect-error invalid argument
88 | await stream.rerender('foo')
89 | }
90 | {
91 | // prop type is implicitly set via the render function argument
92 | const stream = await renderHookToSnapshotStream((_arg1: string) => {})
93 | // @ts-expect-error should not be assignable
94 | const _test1: SnapshotStream = stream
95 | const _test2: SnapshotStream = stream
96 | // @ts-expect-error missing argument
97 | await stream.rerender()
98 | await stream.rerender('foo')
99 | }
100 | {
101 | // prop type is implicitly set via the initialProps argument
102 | const stream = await renderHookToSnapshotStream(() => {}, {
103 | initialProps: 'initial',
104 | })
105 | // @ts-expect-error should not be assignable
106 | const _test1: SnapshotStream = stream
107 | const _test2: SnapshotStream = stream
108 | // @ts-expect-error missing argument
109 | await stream.rerender()
110 | await stream.rerender('foo')
111 | }
112 | {
113 | // argument is optional
114 | const stream = await renderHookToSnapshotStream((_arg1?: string) => {})
115 |
116 | const _test1: SnapshotStream = stream
117 | const _test2: SnapshotStream = stream
118 | const _test3: SnapshotStream = stream
119 | await stream.rerender()
120 | await stream.rerender('foo')
121 | }
122 | })
123 |
--------------------------------------------------------------------------------
/src/assertable.ts:
--------------------------------------------------------------------------------
1 | import {type RenderStream} from './renderStream/createRenderStream.js'
2 |
3 | export const assertableSymbol = Symbol.for(
4 | '@testing-library/react-render-stream:assertable',
5 | )
6 |
7 | /**
8 | * A function or object that can be used in assertions, like e.g.
9 | ```ts
10 | expect(assertable).toRerender()
11 | expect(assertable).not.toRerender()
12 | expect(assertable).toRenderExactlyTimes(3)
13 | ```
14 | */
15 | export type Assertable = {
16 | [assertableSymbol]: RenderStream
17 | }
18 |
19 | export function markAssertable(
20 | assertable: T,
21 | stream: RenderStream,
22 | ): T & Assertable {
23 | return Object.assign(assertable, {
24 | [assertableSymbol]: stream,
25 | })
26 | }
27 |
--------------------------------------------------------------------------------
/src/disableActEnvironment.ts:
--------------------------------------------------------------------------------
1 | import {getConfig} from '@testing-library/dom'
2 |
3 | const dispose: typeof Symbol.dispose =
4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
5 | Symbol.dispose ?? Symbol.for('nodejs.dispose')
6 |
7 | export interface DisableActEnvironmentOptions {
8 | /**
9 | * If `true`, all modifications of values set by `disableActEnvironment`
10 | * will be prevented until `cleanup` is called.
11 | *
12 | * @default true
13 | */
14 | preventModification?: boolean
15 |
16 | /**
17 | * If `true`, will change the configuration of the testing library to
18 | * prevent auto-wrapping e.g. `userEvent` calls in `act`.
19 | *
20 | * @default true
21 | */
22 | adjustTestingLibConfig?: boolean
23 | }
24 |
25 | /**
26 | * Helper to temporarily disable a React 18+ act environment.
27 | *
28 | * By default, this also adjusts the configuration of @testing-library/dom
29 | * to prevent auto-wrapping of user events in `act`, as well as preventing
30 | * all modifications of values set by this method until `cleanup` is called
31 | * or the returned `Disposable` is disposed of.
32 | *
33 | * Both of these behaviors can be disabled with the option, of the defaults
34 | * can be changed for all calls to this method by modifying
35 | * `disableActEnvironment.defaultOptions`.
36 | *
37 | * This returns a disposable and can be used in combination with `using` to
38 | * automatically restore the state from before this method call after your test.
39 | *
40 | * @example
41 | * ```ts
42 | * test("my test", () => {
43 | * using _disabledAct = disableActEnvironment();
44 | *
45 | * // your test code here
46 | *
47 | * // as soon as this scope is left, the environment will be cleaned up
48 | * })
49 | * ```
50 | *
51 | * If you can not use the explicit resouce management keyword `using`,
52 | * you can also manually call `cleanup`:
53 | *
54 | * @example
55 | * ```ts
56 | * test("my test", () => {
57 | * const { cleanup } = disableActEnvironment();
58 | *
59 | * try {
60 | * // your test code here
61 | * } finally {
62 | * cleanup();
63 | * }
64 | * })
65 | * ```
66 | *
67 | * For more context on what `act` is and why you shouldn't use it in renderStream tests,
68 | * https://github.com/reactwg/react-18/discussions/102 is probably the best resource we have.
69 | */
70 | export function disableActEnvironment({
71 | preventModification = disableActEnvironment.defaultOptions
72 | .preventModification,
73 | adjustTestingLibConfig = disableActEnvironment.defaultOptions
74 | .adjustTestingLibConfig,
75 | }: DisableActEnvironmentOptions = {}): {cleanup: () => void} & Disposable {
76 | const typedGlobal = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean}
77 | const cleanupFns: Array<() => void> = []
78 |
79 | // core functionality
80 | {
81 | const previous = typedGlobal.IS_REACT_ACT_ENVIRONMENT
82 | cleanupFns.push(() => {
83 | Object.defineProperty(typedGlobal, 'IS_REACT_ACT_ENVIRONMENT', {
84 | value: previous,
85 | writable: true,
86 | configurable: true,
87 | })
88 | })
89 | Object.defineProperty(
90 | typedGlobal,
91 | 'IS_REACT_ACT_ENVIRONMENT',
92 | getNewPropertyDescriptor(false, preventModification),
93 | )
94 | }
95 |
96 | if (adjustTestingLibConfig) {
97 | const config = getConfig()
98 | // eslint-disable-next-line @typescript-eslint/unbound-method
99 | const {asyncWrapper, eventWrapper} = config
100 | cleanupFns.push(() => {
101 | Object.defineProperty(config, 'asyncWrapper', {
102 | value: asyncWrapper,
103 | writable: true,
104 | configurable: true,
105 | })
106 | Object.defineProperty(config, 'eventWrapper', {
107 | value: eventWrapper,
108 | writable: true,
109 | configurable: true,
110 | })
111 | })
112 |
113 | Object.defineProperty(
114 | config,
115 | 'asyncWrapper',
116 | getNewPropertyDescriptor(
117 | fn => fn(),
118 | preventModification,
119 | ),
120 | )
121 | Object.defineProperty(
122 | config,
123 | 'eventWrapper',
124 | getNewPropertyDescriptor(
125 | fn => fn(),
126 | preventModification,
127 | ),
128 | )
129 | }
130 |
131 | function cleanup() {
132 | while (cleanupFns.length > 0) {
133 | cleanupFns.pop()!()
134 | }
135 | }
136 | return {
137 | cleanup,
138 | [dispose]: cleanup,
139 | }
140 | }
141 |
142 | /**
143 | * Default options for `disableActEnvironment`.
144 | *
145 | * This can be modified to change the default options for all calls to `disableActEnvironment`.
146 | */
147 | disableActEnvironment.defaultOptions = {
148 | preventModification: true,
149 | adjustTestingLibConfig: true,
150 | } satisfies Required as Required
151 |
152 | function getNewPropertyDescriptor(
153 | value: T,
154 | preventModification: boolean,
155 | ): PropertyDescriptor {
156 | return preventModification
157 | ? {
158 | configurable: true,
159 | enumerable: true,
160 | get() {
161 | return value
162 | },
163 | set() {},
164 | }
165 | : {
166 | configurable: true,
167 | enumerable: true,
168 | writable: true,
169 | value,
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/expect/__tests__/renderStreamMatchers.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */
2 | import {EventEmitter} from 'node:events'
3 | import {describe, test, expect} from '@jest/globals'
4 | import {
5 | createRenderStream,
6 | renderHookToSnapshotStream,
7 | } from '@testing-library/react-render-stream'
8 | import * as React from 'react'
9 | import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js'
10 |
11 | const testEvents = new EventEmitter<{
12 | rerender: []
13 | }>()
14 |
15 | function useRerender() {
16 | const [, rerender] = React.useReducer(c => c + 1, 0)
17 | React.useEffect(() => {
18 | const cb = () => void rerender()
19 |
20 | testEvents.addListener('rerender', cb)
21 | return () => {
22 | testEvents.removeListener('rerender', cb)
23 | }
24 | }, [])
25 | }
26 |
27 | function RerenderingComponent() {
28 | useRerender()
29 | return null
30 | }
31 |
32 | describe('toRerender', () => {
33 | test('basic functionality', async () => {
34 | const {takeRender, render} = createRenderStream({})
35 |
36 | await render()
37 | await expect(takeRender).toRerender()
38 | await takeRender()
39 |
40 | testEvents.emit('rerender')
41 | await expect(takeRender).toRerender()
42 | await takeRender()
43 |
44 | await expect(takeRender).not.toRerender()
45 | })
46 |
47 | test('works with renderStream object', async () => {
48 | const renderStream = createRenderStream({})
49 |
50 | await renderStream.render()
51 | await expect(renderStream).toRerender()
52 | await renderStream.takeRender()
53 |
54 | testEvents.emit('rerender')
55 | await expect(renderStream).toRerender()
56 | await renderStream.takeRender()
57 |
58 | await expect(renderStream).not.toRerender()
59 | })
60 |
61 | test('works with takeSnapshot function', async () => {
62 | const {takeSnapshot} = await renderHookToSnapshotStream(() => useRerender())
63 |
64 | await expect(takeSnapshot).toRerender()
65 | await takeSnapshot()
66 |
67 | testEvents.emit('rerender')
68 | await expect(takeSnapshot).toRerender()
69 | await takeSnapshot()
70 |
71 | await expect(takeSnapshot).not.toRerender()
72 | })
73 |
74 | test('works with snapshotStream', async () => {
75 | const snapshotStream = await renderHookToSnapshotStream(() => useRerender())
76 |
77 | await expect(snapshotStream).toRerender()
78 | await snapshotStream.takeSnapshot()
79 |
80 | testEvents.emit('rerender')
81 | await expect(snapshotStream).toRerender()
82 | await snapshotStream.takeSnapshot()
83 |
84 | await expect(snapshotStream).not.toRerender()
85 | })
86 |
87 | test("errors when it rerenders, but shouldn't", async () => {
88 | const {takeRender, render} = createRenderStream({})
89 |
90 | await render()
91 | await expect(takeRender).toRerender()
92 | await takeRender()
93 |
94 | testEvents.emit('rerender')
95 | const error = await getExpectErrorMessage(
96 | expect(takeRender).not.toRerender(),
97 | )
98 | expect(error).toMatchInlineSnapshot(`
99 | expect(received).not.toRerender(expected)
100 |
101 | Expected component to not rerender, but it did.
102 | `)
103 | })
104 |
105 | test("errors when it should rerender, but doesn't", async () => {
106 | const {takeRender, render} = createRenderStream({})
107 |
108 | await render()
109 | await expect(takeRender).toRerender()
110 | await takeRender()
111 |
112 | const error = await getExpectErrorMessage(expect(takeRender).toRerender())
113 | expect(error).toMatchInlineSnapshot(`
114 | expect(received).toRerender(expected)
115 |
116 | Expected component to rerender, but it did not.
117 | `)
118 | })
119 | })
120 |
121 | describe('toRenderExactlyTimes', () => {
122 | test('basic functionality', async () => {
123 | const {takeRender, render} = createRenderStream({})
124 |
125 | await render()
126 | testEvents.emit('rerender')
127 |
128 | await expect(takeRender).toRenderExactlyTimes(2)
129 | })
130 |
131 | test('works with renderStream object', async () => {
132 | const renderStream = createRenderStream({})
133 |
134 | await renderStream.render()
135 | testEvents.emit('rerender')
136 |
137 | await expect(renderStream).toRenderExactlyTimes(2)
138 | })
139 |
140 | test('works with takeSnapshot function', async () => {
141 | const {takeSnapshot} = await renderHookToSnapshotStream(() => useRerender())
142 | testEvents.emit('rerender')
143 |
144 | await expect(takeSnapshot).toRenderExactlyTimes(2)
145 | })
146 |
147 | test('works with snapshotStream', async () => {
148 | const snapshotStream = await renderHookToSnapshotStream(() => useRerender())
149 | testEvents.emit('rerender')
150 |
151 | await expect(snapshotStream).toRenderExactlyTimes(2)
152 | })
153 |
154 | test('errors when the count of rerenders is wrong', async () => {
155 | const {takeRender, render} = createRenderStream({})
156 |
157 | await render()
158 | testEvents.emit('rerender')
159 |
160 | const error = await getExpectErrorMessage(
161 | expect(takeRender).toRenderExactlyTimes(3),
162 | )
163 | expect(error).toMatchInlineSnapshot(`
164 | expect(received).toRenderExactlyTimes(expected)
165 |
166 | Expected component to render exactly 3 times.
167 | It rendered 2 times.
168 | `)
169 | })
170 |
171 | test('errors when the count of rerenders is right (inverted)', async () => {
172 | const {takeRender, render} = createRenderStream({})
173 |
174 | await render()
175 | testEvents.emit('rerender')
176 |
177 | const error = await getExpectErrorMessage(
178 | expect(takeRender).not.toRenderExactlyTimes(2),
179 | )
180 | expect(error).toMatchInlineSnapshot(`
181 | expect(received).not.toRenderExactlyTimes(expected)
182 |
183 | Expected component to not render exactly 2 times.
184 | It rendered 2 times.
185 | `)
186 | })
187 | })
188 |
--------------------------------------------------------------------------------
/src/expect/index.ts:
--------------------------------------------------------------------------------
1 | import {expect} from 'expect'
2 | import {
3 | toRerender,
4 | toRenderExactlyTimes,
5 | type RenderStreamMatchers,
6 | } from './renderStreamMatchers.js'
7 |
8 | expect.extend({
9 | toRerender,
10 | toRenderExactlyTimes,
11 | })
12 |
13 | declare module 'expect' {
14 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
15 | interface Matchers, T = unknown>
16 | extends RenderStreamMatchers {}
17 | }
18 |
--------------------------------------------------------------------------------
/src/expect/renderStreamMatchers.ts:
--------------------------------------------------------------------------------
1 | import {MatcherContext, type MatcherFunction} from 'expect'
2 | import {
3 | WaitForRenderTimeoutError,
4 | type Assertable,
5 | type NextRenderOptions,
6 | type RenderStream,
7 | } from '@testing-library/react-render-stream'
8 | // explicitly imported the symbol from the internal file
9 | // this will bundle the `Symbol.for` call twice, but we keep it private
10 | import {assertableSymbol} from '../assertable.js'
11 |
12 | export interface RenderStreamMatchers {
13 | toRerender: T extends RenderStream | Assertable
14 | ? (options?: NextRenderOptions) => Promise
15 | : {
16 | error: 'matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance'
17 | }
18 |
19 | toRenderExactlyTimes: T extends RenderStream | Assertable
20 | ? (count: number, options?: NextRenderOptions) => Promise
21 | : {
22 | error: 'matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance'
23 | }
24 | }
25 |
26 | export const toRerender: MatcherFunction<[options?: NextRenderOptions]> =
27 | async function toRerender(this: MatcherContext, actual, options) {
28 | const _stream = actual as RenderStream | Assertable
29 | const stream =
30 | assertableSymbol in _stream ? _stream[assertableSymbol] : _stream
31 | const hint = this.utils.matcherHint('toRerender', undefined, undefined, {
32 | isNot: this.isNot,
33 | })
34 | let pass = true
35 | try {
36 | await stream.peekRender({timeout: 100, ...options})
37 | } catch (e) {
38 | if (e instanceof WaitForRenderTimeoutError) {
39 | pass = false
40 | } else {
41 | throw e
42 | }
43 | }
44 |
45 | return {
46 | pass,
47 | message() {
48 | return (
49 | `${hint}\n\nExpected component to${pass ? ' not' : ''} rerender, ` +
50 | `but it did${pass ? '' : ' not'}.`
51 | )
52 | },
53 | }
54 | }
55 |
56 | /** to be thrown to "break" test execution and fail it */
57 | const failed = new Error()
58 |
59 | export const toRenderExactlyTimes: MatcherFunction<
60 | [times: number, options?: NextRenderOptions]
61 | > = async function toRenderExactlyTimes(
62 | this: MatcherContext,
63 | actual,
64 | times,
65 | optionsPerRender,
66 | ) {
67 | const _stream = actual as RenderStream | Assertable
68 | const stream =
69 | assertableSymbol in _stream ? _stream[assertableSymbol] : _stream
70 | const options = {timeout: 100, ...optionsPerRender}
71 | const hint = this.utils.matcherHint(
72 | 'toRenderExactlyTimes',
73 | undefined,
74 | undefined,
75 | {isNot: this.isNot},
76 | )
77 | let pass = true
78 | try {
79 | if (stream.totalRenderCount() > times) {
80 | throw failed
81 | }
82 | try {
83 | while (stream.totalRenderCount() < times) {
84 | // eslint-disable-next-line no-await-in-loop
85 | await stream.waitForNextRender(options)
86 | }
87 | } catch (e) {
88 | // timeouts here should just fail the test, rethrow other errors
89 | throw e instanceof WaitForRenderTimeoutError ? failed : e
90 | }
91 | try {
92 | await stream.waitForNextRender(options)
93 | } catch (e) {
94 | // we are expecting a timeout here, so swallow that error, rethrow others
95 | if (!(e instanceof WaitForRenderTimeoutError)) {
96 | throw e
97 | }
98 | }
99 | } catch (e) {
100 | if (e === failed) {
101 | pass = false
102 | } else {
103 | throw e
104 | }
105 | }
106 | return {
107 | pass,
108 | message() {
109 | return (
110 | `${
111 | hint
112 | }\n\nExpected component to${pass ? ' not' : ''} render exactly ${times} times.` +
113 | `\nIt rendered ${stream.totalRenderCount()} times.`
114 | )
115 | },
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/react-render-stream/expect'
2 | import {cleanup} from '@testing-library/react-render-stream/pure'
3 | export * from '@testing-library/react-render-stream/pure'
4 |
5 | const global = globalThis as {afterEach?: (fn: () => void) => void}
6 | if (global.afterEach) {
7 | global.afterEach(cleanup)
8 | }
9 |
--------------------------------------------------------------------------------
/src/pure.ts:
--------------------------------------------------------------------------------
1 | export type {
2 | NextRenderOptions,
3 | RenderStream,
4 | RenderStreamWithRenderFn,
5 | RenderStreamOptions,
6 | } from './renderStream/createRenderStream.js'
7 | export {
8 | createRenderStream,
9 | WaitForRenderTimeoutError,
10 | } from './renderStream/createRenderStream.js'
11 | export {useTrackRenders} from './renderStream/useTrackRenders.js'
12 |
13 | export type {SyncScreen} from './renderStream/Render.js'
14 |
15 | export {renderHookToSnapshotStream} from './renderHookToSnapshotStream.js'
16 | export type {SnapshotStream} from './renderHookToSnapshotStream.js'
17 |
18 | export type {Assertable} from './assertable.js'
19 |
20 | export {
21 | cleanup,
22 | type RenderWithoutActAsync as AsyncRenderFn,
23 | } from './renderWithoutAct.js'
24 | export {
25 | disableActEnvironment,
26 | type DisableActEnvironmentOptions,
27 | } from './disableActEnvironment.js'
28 |
--------------------------------------------------------------------------------
/src/renderHookToSnapshotStream.tsx:
--------------------------------------------------------------------------------
1 | import {type RenderHookOptions} from '@testing-library/react/pure.js'
2 | import React from 'rehackt'
3 | import {createRenderStream} from './renderStream/createRenderStream.js'
4 | import {type NextRenderOptions} from './renderStream/createRenderStream.js'
5 | import {Render} from './renderStream/Render.js'
6 | import {Assertable, assertableSymbol, markAssertable} from './assertable.js'
7 |
8 | export interface SnapshotStream extends Assertable {
9 | /**
10 | * An array of all renders that have happened so far.
11 | * Errors thrown during component render will be captured here, too.
12 | */
13 | renders: Array<
14 | | Render<{value: Snapshot}, never>
15 | | {phase: 'snapshotError'; count: number; error: unknown}
16 | >
17 | /**
18 | * Peeks the next render from the current iterator position, without advancing the iterator.
19 | * If no render has happened yet, it will wait for the next render to happen.
20 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout
21 | */
22 | peekSnapshot(options?: NextRenderOptions): Promise
23 | /**
24 | * Iterates to the next render and returns it.
25 | * If no render has happened yet, it will wait for the next render to happen.
26 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout
27 | */
28 | takeSnapshot: Assertable &
29 | ((options?: NextRenderOptions) => Promise)
30 | /**
31 | * Returns the total number of renders.
32 | */
33 | totalSnapshotCount(): number
34 | /**
35 | * Returns the current render.
36 | * @throws {Error} if no render has happened yet
37 | */
38 | getCurrentSnapshot(): Snapshot
39 | /**
40 | * Waits for the next render to happen.
41 | * Does not advance the render iterator.
42 | */
43 | waitForNextSnapshot(options?: NextRenderOptions): Promise
44 | rerender: (rerenderCallbackProps: VoidOptionalArg) => Promise
45 | unmount: () => void
46 | }
47 |
48 | /**
49 | * if `Arg` can be `undefined`, replace it with `void` to make type represent an optional argument in a function argument position
50 | */
51 | type VoidOptionalArg = Arg extends any // distribute members of a potential `Props` union
52 | ? undefined extends Arg
53 | ? // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
54 | void
55 | : Arg
56 | : Arg
57 |
58 | export async function renderHookToSnapshotStream(
59 | renderCallback: (props: Props) => ReturnValue,
60 | {initialProps, ...renderOptions}: RenderHookOptions = {},
61 | ): Promise> {
62 | const {render, ...stream} = createRenderStream<{value: ReturnValue}, never>()
63 |
64 | const HookComponent: React.FC<{arg: Props}> = props => {
65 | stream.replaceSnapshot({value: renderCallback(props.arg)})
66 | return null
67 | }
68 |
69 | const {rerender: baseRerender, unmount} = await render(
70 | ,
71 | renderOptions,
72 | )
73 |
74 | function rerender(rerenderCallbackProps: VoidOptionalArg) {
75 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
76 | return baseRerender()
77 | }
78 |
79 | return {
80 | [assertableSymbol]: stream,
81 | renders: stream.renders,
82 | totalSnapshotCount: stream.totalRenderCount,
83 | async peekSnapshot(options) {
84 | return (await stream.peekRender(options)).snapshot.value
85 | },
86 | takeSnapshot: markAssertable(async function takeSnapshot(options) {
87 | return (await stream.takeRender(options)).snapshot.value
88 | }, stream),
89 | getCurrentSnapshot() {
90 | return stream.getCurrentRender().snapshot.value
91 | },
92 | async waitForNextSnapshot(options) {
93 | return (await stream.waitForNextRender(options)).snapshot.value
94 | },
95 | rerender,
96 | unmount,
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/renderStream/Render.tsx:
--------------------------------------------------------------------------------
1 | import {screen, getQueriesForElement, Screen} from '@testing-library/dom'
2 | import {JSDOM, VirtualConsole} from 'jsdom'
3 | import {
4 | BoundSyncFunctions,
5 | type Queries,
6 | type SyncQueries,
7 | } from './syncQueries.js'
8 |
9 | export interface BaseRender {
10 | id: string
11 | phase: 'mount' | 'update' | 'nested-update'
12 | actualDuration: number
13 | baseDuration: number
14 | startTime: number
15 | commitTime: number
16 | /**
17 | * The number of renders that have happened so far (including this render).
18 | */
19 | count: number
20 | }
21 |
22 | export type SyncScreen =
23 | BoundSyncFunctions & Pick
24 |
25 | export interface Render
26 | extends BaseRender {
27 | /**
28 | * The snapshot, as returned by the `takeSnapshot` option of `createRenderStream`.
29 | */
30 | snapshot: Snapshot
31 | /**
32 | * A DOM snapshot of the rendered component, if the `snapshotDOM`
33 | * option of `createRenderStream` was enabled.
34 | */
35 | readonly domSnapshot: HTMLElement
36 | /**
37 | * Returns a callback to receive a `screen` instance that is scoped to the
38 | * DOM snapshot of this `Render` instance.
39 | * Note: this is used as a callback to prevent linter errors.
40 | * @example
41 | * ```diff
42 | * const { withinDOM } = RenderedComponent.takeRender();
43 | * -expect(screen.getByText("foo")).toBeInTheDocument();
44 | * +expect(withinDOM().getByText("foo")).toBeInTheDocument();
45 | * ```
46 | */
47 | withinDOM: () => SyncScreen
48 |
49 | renderedComponents: Array
50 | }
51 |
52 | export class RenderInstance
53 | implements Render
54 | {
55 | id: string
56 | phase: 'mount' | 'update' | 'nested-update'
57 | actualDuration: number
58 | baseDuration: number
59 | startTime: number
60 | commitTime: number
61 | count: number
62 | public snapshot: Snapshot
63 | private stringifiedDOM: string | undefined
64 | public renderedComponents: Array
65 | private queries: Q
66 |
67 | constructor(
68 | baseRender: BaseRender,
69 | snapshot: Snapshot,
70 | stringifiedDOM: string | undefined,
71 | renderedComponents: Array,
72 | queries: Q,
73 | ) {
74 | this.snapshot = snapshot
75 | this.stringifiedDOM = stringifiedDOM
76 | this.renderedComponents = renderedComponents
77 | this.id = baseRender.id
78 | this.phase = baseRender.phase
79 | this.actualDuration = baseRender.actualDuration
80 | this.baseDuration = baseRender.baseDuration
81 | this.startTime = baseRender.startTime
82 | this.commitTime = baseRender.commitTime
83 | this.count = baseRender.count
84 | this.queries = queries
85 | }
86 |
87 | private _domSnapshot: HTMLElement | undefined
88 | get domSnapshot() {
89 | if (this._domSnapshot) return this._domSnapshot
90 | if (!this.stringifiedDOM) {
91 | throw new Error(
92 | 'DOM snapshot is not available - please set the `snapshotDOM` option',
93 | )
94 | }
95 |
96 | const virtualConsole = new VirtualConsole()
97 | virtualConsole.on('jsdomError', (error: any) => {
98 | throw error
99 | })
100 |
101 | const snapDOM = new JSDOM(this.stringifiedDOM, {
102 | runScripts: 'dangerously',
103 | virtualConsole,
104 | })
105 | const document = snapDOM.window.document
106 | const body = document.body
107 | const script = document.createElement('script')
108 | script.type = 'text/javascript'
109 | script.text = `
110 | ${errorOnDomInteraction.toString()};
111 | ${errorOnDomInteraction.name}();
112 | `
113 | body.appendChild(script)
114 | body.removeChild(script)
115 |
116 | return (this._domSnapshot = body)
117 | }
118 |
119 | get withinDOM(): () => SyncScreen {
120 | const snapScreen = Object.assign(
121 | getQueriesForElement(
122 | this.domSnapshot,
123 | this.queries,
124 | ) as any as BoundSyncFunctions,
125 | {
126 | debug: (
127 | ...[dom = this.domSnapshot, ...rest]: Parameters
128 | ) => screen.debug(dom, ...rest),
129 | logTestingPlaygroundURL: (
130 | ...[dom = this.domSnapshot, ...rest]: Parameters<
131 | typeof screen.logTestingPlaygroundURL
132 | >
133 | ) => screen.logTestingPlaygroundURL(dom, ...rest),
134 | },
135 | )
136 | return () => snapScreen
137 | }
138 | }
139 |
140 | export function errorOnDomInteraction() {
141 | const events: Array = [
142 | 'auxclick',
143 | 'blur',
144 | 'change',
145 | 'click',
146 | 'copy',
147 | 'cut',
148 | 'dblclick',
149 | 'drag',
150 | 'dragend',
151 | 'dragenter',
152 | 'dragleave',
153 | 'dragover',
154 | 'dragstart',
155 | 'drop',
156 | 'focus',
157 | 'focusin',
158 | 'focusout',
159 | 'input',
160 | 'keydown',
161 | 'keypress',
162 | 'keyup',
163 | 'mousedown',
164 | 'mouseenter',
165 | 'mouseleave',
166 | 'mousemove',
167 | 'mouseout',
168 | 'mouseover',
169 | 'mouseup',
170 | 'paste',
171 | 'pointercancel',
172 | 'pointerdown',
173 | 'pointerenter',
174 | 'pointerleave',
175 | 'pointermove',
176 | 'pointerout',
177 | 'pointerover',
178 | 'pointerup',
179 | 'scroll',
180 | 'select',
181 | 'selectionchange',
182 | 'selectstart',
183 | 'submit',
184 | 'toggle',
185 | 'touchcancel',
186 | 'touchend',
187 | 'touchmove',
188 | 'touchstart',
189 | 'wheel',
190 | ]
191 | function warnOnDomInteraction() {
192 | throw new Error(`
193 | DOM interaction with a snapshot detected in test.
194 | Please don't interact with the DOM you get from \`withinDOM\`,
195 | but still use \`screen\` to get elements for simulating user interaction.
196 | `)
197 | }
198 | events.forEach(event => {
199 | document.addEventListener(event, warnOnDomInteraction)
200 | })
201 | }
202 |
--------------------------------------------------------------------------------
/src/renderStream/__tests__/createRenderStream.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-use-before-define */
2 | import {jest, describe, test, expect} from '@jest/globals'
3 | import {createRenderStream} from '@testing-library/react-render-stream'
4 | import * as React from 'react'
5 | import {ErrorBoundary} from 'react-error-boundary'
6 | import {userEvent} from '@testing-library/user-event'
7 | import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js'
8 |
9 | function CounterForm({
10 | value,
11 | onIncrement,
12 | }: {
13 | value: number
14 | onIncrement: () => void
15 | }) {
16 | return (
17 |
26 | )
27 | }
28 |
29 | describe('snapshotDOM', () => {
30 | test('basic functionality', async () => {
31 | function Counter() {
32 | const [value, setValue] = React.useState(0)
33 | return (
34 | setValue(v => v + 1)} />
35 | )
36 | }
37 |
38 | const {takeRender, render} = createRenderStream({
39 | snapshotDOM: true,
40 | })
41 | const utils = await render()
42 | const incrementButton = utils.getByText('Increment')
43 | await userEvent.click(incrementButton)
44 | await userEvent.click(incrementButton)
45 | {
46 | const {withinDOM} = await takeRender()
47 | const input = withinDOM().getByLabelText('Value')
48 | expect(input.value).toBe('0')
49 | }
50 | {
51 | const {withinDOM} = await takeRender()
52 | // a one-off to test that `queryBy` works and accepts a type argument
53 | const input = withinDOM().queryByLabelText('Value')!
54 | expect(input.value).toBe('1')
55 | }
56 | {
57 | const {withinDOM} = await takeRender()
58 | const input = withinDOM().getByLabelText('Value')
59 | expect(input.value).toBe('2')
60 | }
61 | })
62 |
63 | test('errors when triggering events on rendered elemenst', async () => {
64 | function Counter() {
65 | const [value, setValue] = React.useState(0)
66 | return (
67 | setValue(v => v + 1)} />
68 | )
69 | }
70 |
71 | const {takeRender, render} = createRenderStream({
72 | snapshotDOM: true,
73 | })
74 | await render()
75 | {
76 | const {withinDOM} = await takeRender()
77 | const snapshotIncrementButton = withinDOM().getByText('Increment')
78 | try {
79 | await userEvent.click(snapshotIncrementButton)
80 | } catch (error) {
81 | expect(error).toMatchInlineSnapshot(`
82 | [Error: Uncaught [Error:
83 | DOM interaction with a snapshot detected in test.
84 | Please don't interact with the DOM you get from \`withinDOM\`,
85 | but still use \`screen\` to get elements for simulating user interaction.
86 | ]]
87 | `)
88 | }
89 | }
90 | })
91 |
92 | test('queries option', async () => {
93 | function Component() {
94 | return null
95 | }
96 | const queries = {
97 | foo: (_: any) => {
98 | return null
99 | },
100 | }
101 |
102 | const {takeRender, render} = createRenderStream({
103 | snapshotDOM: true,
104 | queries,
105 | })
106 | await render()
107 |
108 | const {withinDOM} = await takeRender()
109 | expect(withinDOM().foo()).toBe(null)
110 | function _typeTest() {
111 | // @ts-expect-error should not be present
112 | withinDOM().getByText
113 | withinDOM().debug()
114 | const _str: string = withinDOM().logTestingPlaygroundURL()
115 | }
116 | })
117 | })
118 |
119 | describe('replaceSnapshot', () => {
120 | test('basic functionality', async () => {
121 | function Counter() {
122 | const [value, setValue] = React.useState(0)
123 | replaceSnapshot({value})
124 | return (
125 | setValue(v => v + 1)} />
126 | )
127 | }
128 |
129 | const {takeRender, replaceSnapshot, render} = createRenderStream<{
130 | value: number
131 | }>()
132 | const utils = await render()
133 | const incrementButton = utils.getByText('Increment')
134 | await userEvent.click(incrementButton)
135 | await userEvent.click(incrementButton)
136 | {
137 | const {snapshot} = await takeRender()
138 | expect(snapshot).toEqual({value: 0})
139 | }
140 | {
141 | const {snapshot} = await takeRender()
142 | expect(snapshot).toEqual({value: 1})
143 | }
144 | {
145 | const {snapshot} = await takeRender()
146 | expect(snapshot).toEqual({value: 2})
147 | }
148 | })
149 | describe('callback notation', () => {
150 | test('basic functionality', async () => {
151 | function Counter() {
152 | const [value, setValue] = React.useState(0)
153 | replaceSnapshot(oldSnapshot => ({...oldSnapshot, value}))
154 | return (
155 | setValue(v => v + 1)} />
156 | )
157 | }
158 |
159 | const {takeRender, replaceSnapshot, render} = createRenderStream({
160 | initialSnapshot: {unrelatedValue: 'unrelated', value: -1},
161 | })
162 | const utils = await render()
163 | const incrementButton = utils.getByText('Increment')
164 | await userEvent.click(incrementButton)
165 | await userEvent.click(incrementButton)
166 | {
167 | const {snapshot} = await takeRender()
168 | expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 0})
169 | }
170 | {
171 | const {snapshot} = await takeRender()
172 | expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 1})
173 | }
174 | {
175 | const {snapshot} = await takeRender()
176 | expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 2})
177 | }
178 | })
179 | test('requires initialSnapshot', async () => {
180 | function Counter() {
181 | const [value, setValue] = React.useState(0)
182 | replaceSnapshot(() => ({value}))
183 | return (
184 | setValue(v => v + 1)} />
185 | )
186 | }
187 |
188 | const {replaceSnapshot, render} = createRenderStream<{
189 | value: number
190 | }>()
191 | let caughtError: Error
192 |
193 | const spy = jest.spyOn(console, 'error')
194 | spy.mockImplementation(() => {})
195 | await render(
196 | {
198 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
199 | caughtError = error
200 | return null
201 | }}
202 | >
203 |
204 | ,
205 | )
206 | spy.mockRestore()
207 |
208 | expect(caughtError!).toMatchInlineSnapshot(
209 | `[Error: Cannot use a function to update the snapshot if no initial snapshot was provided.]`,
210 | )
211 | })
212 | })
213 | })
214 |
215 | describe('onRender', () => {
216 | test('basic functionality', async () => {
217 | function Counter() {
218 | const [value, setValue] = React.useState(0)
219 | replaceSnapshot({value})
220 | return (
221 | setValue(v => v + 1)} />
222 | )
223 | }
224 |
225 | const {takeRender, replaceSnapshot, render} = createRenderStream<{
226 | value: number
227 | }>({
228 | onRender(info) {
229 | // can use expect here
230 | expect(info.count).toBe(info.snapshot.value + 1)
231 | },
232 | })
233 | const utils = await render()
234 | const incrementButton = utils.getByText('Increment')
235 | await userEvent.click(incrementButton)
236 | await userEvent.click(incrementButton)
237 | await takeRender()
238 | await takeRender()
239 | await takeRender()
240 | })
241 |
242 | test('errors in `onRender` propagate to the associated `takeRender` call', async () => {
243 | function Counter() {
244 | const [value, setValue] = React.useState(0)
245 | return (
246 | setValue(v => v + 1)} />
247 | )
248 | }
249 |
250 | const {takeRender, render} = createRenderStream({
251 | onRender(info) {
252 | expect(info.count).toBe(1)
253 | },
254 | })
255 |
256 | const utils = await render()
257 | const incrementButton = utils.getByText('Increment')
258 | await userEvent.click(incrementButton)
259 | await userEvent.click(incrementButton)
260 | await takeRender()
261 | const error = await getExpectErrorMessage(takeRender())
262 |
263 | expect(error).toMatchInlineSnapshot(`
264 | expect(received).toBe(expected) // Object.is equality
265 |
266 | Expected: 1
267 | Received: 2
268 | `)
269 | })
270 |
271 | test('returned `rerender` returns a promise that resolves', async () => {
272 | function Component() {
273 | return null
274 | }
275 |
276 | const {takeRender, render} = createRenderStream()
277 | const {rerender} = await render()
278 | await takeRender()
279 | const promise: Promise = rerender()
280 | expect(promise).toBeInstanceOf(Promise)
281 | await promise
282 | await takeRender()
283 | })
284 | })
285 |
--------------------------------------------------------------------------------
/src/renderStream/__tests__/useTrackRenders.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */
2 | import {jest, describe, test, expect, beforeEach} from '@jest/globals'
3 | import {
4 | createRenderStream,
5 | useTrackRenders,
6 | } from '@testing-library/react-render-stream'
7 | import * as React from 'react'
8 | import {ErrorBoundary} from 'react-error-boundary'
9 | import {__use} from '../../__testHelpers__/useShim.js'
10 |
11 | type AsyncState =
12 | | {status: 'loading'}
13 | | {status: 'success'; data: string}
14 | | {status: 'error'; error: Error}
15 |
16 | describe('non-suspense use cases', () => {
17 | let asyncAction = Promise.withResolvers()
18 | beforeEach(() => {
19 | asyncAction = Promise.withResolvers()
20 | void asyncAction.promise.catch(() => {
21 | /* avoid uncaught promise rejection */
22 | })
23 | })
24 | function ErrorComponent() {
25 | useTrackRenders()
26 | return null
27 | }
28 | function DataComponent() {
29 | useTrackRenders()
30 | return null
31 | }
32 | function LoadingComponent() {
33 | useTrackRenders()
34 | return null
35 | }
36 | function App() {
37 | useTrackRenders()
38 | const [state, setState] = React.useState({status: 'loading'})
39 | React.useEffect(() => {
40 | let canceled = false
41 | void (async function iife() {
42 | try {
43 | const data = await asyncAction.promise
44 | if (canceled) return
45 | setState({status: 'success', data})
46 | } catch (error: any) {
47 | if (canceled) return
48 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
49 | setState({status: 'error', error})
50 | }
51 | })()
52 | return () => {
53 | canceled = true
54 | }
55 | }, [asyncAction.promise])
56 | return state.status === 'loading' ? (
57 |
58 | ) : state.status === 'error' ? (
59 |
60 | ) : (
61 |
62 | )
63 | }
64 |
65 | test('basic functionality', async () => {
66 | const {takeRender, render} = createRenderStream()
67 | await render()
68 | asyncAction.resolve('data')
69 | {
70 | const {renderedComponents} = await takeRender()
71 | expect(renderedComponents).toEqual([App, LoadingComponent])
72 | }
73 | {
74 | const {renderedComponents} = await takeRender()
75 | expect(renderedComponents).toEqual([App, DataComponent])
76 | }
77 | })
78 |
79 | test('error path', async () => {
80 | const {takeRender, render} = createRenderStream()
81 | await render()
82 | asyncAction.reject(new Error('error'))
83 | {
84 | const {renderedComponents} = await takeRender()
85 | expect(renderedComponents).toEqual([App, LoadingComponent])
86 | }
87 | {
88 | const {renderedComponents} = await takeRender()
89 | expect(renderedComponents).toEqual([App, ErrorComponent])
90 | }
91 | })
92 | })
93 |
94 | describe('suspense use cases', () => {
95 | let asyncAction = Promise.withResolvers()
96 | beforeEach(() => {
97 | asyncAction = Promise.withResolvers()
98 | })
99 | function ErrorComponent() {
100 | useTrackRenders()
101 | return null
102 | }
103 | function DataComponent() {
104 | useTrackRenders()
105 | __use(asyncAction.promise)
106 | return null
107 | }
108 | function LoadingComponent() {
109 | useTrackRenders()
110 | return null
111 | }
112 | function App() {
113 | useTrackRenders()
114 | return (
115 |
116 | }>
117 |
118 |
119 |
120 | )
121 | }
122 |
123 | test('basic functionality', async () => {
124 | const {takeRender, render} = createRenderStream()
125 | await render()
126 | asyncAction.resolve('data')
127 | {
128 | const {renderedComponents} = await takeRender()
129 | expect(renderedComponents).toEqual([App, LoadingComponent])
130 | }
131 | {
132 | const {renderedComponents} = await takeRender()
133 | expect(renderedComponents).toEqual([DataComponent])
134 | }
135 | })
136 |
137 | test('ErrorBoundary', async () => {
138 | const {takeRender, render} = createRenderStream()
139 | await render()
140 |
141 | const spy = jest.spyOn(console, 'error')
142 | spy.mockImplementation(() => {})
143 | asyncAction.reject(new Error('error'))
144 | {
145 | const {renderedComponents} = await takeRender()
146 | expect(renderedComponents).toEqual([App, LoadingComponent])
147 | }
148 | {
149 | const {renderedComponents} = await takeRender()
150 | expect(renderedComponents).toEqual([ErrorComponent])
151 | }
152 | spy.mockRestore()
153 | })
154 | })
155 |
156 | test('specifying the `name` option', async () => {
157 | function NamedComponent({name, children}: {name: string; children?: any}) {
158 | useTrackRenders({name: `NamedComponent:${name}`})
159 | return <>{children}>
160 | }
161 | const {takeRender, render} = createRenderStream()
162 | await render(
163 | <>
164 |
165 |
166 |
167 |
168 |
169 |
170 | >,
171 | )
172 | {
173 | const {renderedComponents} = await takeRender()
174 | expect(renderedComponents).toEqual([
175 | 'NamedComponent:Darth Vader',
176 | // this relies on the order of `useLayoutEffect` being executed, we have no way to influence that siblings seem "backwards" here
177 | 'NamedComponent:Leia',
178 | 'NamedComponent:Luke',
179 | 'NamedComponent:R2D2',
180 | ])
181 | }
182 | })
183 |
--------------------------------------------------------------------------------
/src/renderStream/context.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'rehackt'
2 |
3 | export interface RenderStreamContextValue {
4 | renderedComponents: Array
5 | }
6 |
7 | const RenderStreamContext = React.createContext<
8 | RenderStreamContextValue | undefined
9 | >(undefined)
10 |
11 | export function RenderStreamContextProvider({
12 | children,
13 | value,
14 | }: {
15 | children: React.ReactNode
16 | value: RenderStreamContextValue
17 | }) {
18 | const parentContext = useRenderStreamContext()
19 |
20 | if (parentContext) {
21 | throw new Error('Render streams should not be nested in the same tree')
22 | }
23 |
24 | return (
25 |
26 | {children}
27 |
28 | )
29 | }
30 |
31 | export function useRenderStreamContext() {
32 | return React.useContext(RenderStreamContext)
33 | }
34 |
--------------------------------------------------------------------------------
/src/renderStream/createRenderStream.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'rehackt'
2 |
3 | import {type RenderOptions} from '@testing-library/react/pure.js'
4 | import {Assertable, markAssertable} from '../assertable.js'
5 | import {
6 | renderWithoutAct,
7 | type RenderWithoutActAsync,
8 | } from '../renderWithoutAct.js'
9 | import {RenderInstance, type Render, type BaseRender} from './Render.js'
10 | import {type RenderStreamContextValue} from './context.js'
11 | import {RenderStreamContextProvider} from './context.js'
12 | import {syncQueries, type Queries, type SyncQueries} from './syncQueries.js'
13 |
14 | export type ValidSnapshot =
15 | // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
16 | void | (object & {/* not a function */ call?: never})
17 |
18 | export interface NextRenderOptions {
19 | timeout?: number
20 | }
21 |
22 | interface ReplaceSnapshot {
23 | (newSnapshot: Snapshot): void
24 | (updateSnapshot: (lastSnapshot: Readonly) => Snapshot): void
25 | }
26 |
27 | interface MergeSnapshot {
28 | (partialSnapshot: Partial): void
29 | (
30 | updatePartialSnapshot: (
31 | lastSnapshot: Readonly,
32 | ) => Partial,
33 | ): void
34 | }
35 |
36 | export interface RenderStream<
37 | Snapshot extends ValidSnapshot,
38 | Q extends Queries = SyncQueries,
39 | > {
40 | // Allows for partial updating of the snapshot by shallow merging the results
41 | mergeSnapshot: MergeSnapshot
42 | // Performs a full replacement of the snapshot
43 | replaceSnapshot: ReplaceSnapshot
44 | /**
45 | * An array of all renders that have happened so far.
46 | * Errors thrown during component render will be captured here, too.
47 | */
48 | renders: Array<
49 | | Render
50 | | {phase: 'snapshotError'; count: number; error: unknown}
51 | >
52 | /**
53 | * Peeks the next render from the current iterator position, without advancing the iterator.
54 | * If no render has happened yet, it will wait for the next render to happen.
55 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout
56 | */
57 | peekRender: (options?: NextRenderOptions) => Promise>
58 | /**
59 | * Iterates to the next render and returns it.
60 | * If no render has happened yet, it will wait for the next render to happen.
61 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout
62 | */
63 | takeRender: Assertable &
64 | ((options?: NextRenderOptions) => Promise>)
65 | /**
66 | * Returns the total number of renders.
67 | */
68 | totalRenderCount: () => number
69 | /**
70 | * Returns the current render.
71 | * @throws {Error} if no render has happened yet
72 | */
73 | getCurrentRender: () => Render
74 | /**
75 | * Waits for the next render to happen.
76 | * Does not advance the render iterator.
77 | */
78 | waitForNextRender: (
79 | options?: NextRenderOptions,
80 | ) => Promise>
81 | }
82 |
83 | export interface RenderStreamWithRenderFn<
84 | Snapshot extends ValidSnapshot,
85 | Q extends Queries = SyncQueries,
86 | > extends RenderStream {
87 | render: RenderWithoutActAsync
88 | }
89 |
90 | export type RenderStreamOptions<
91 | Snapshot extends ValidSnapshot,
92 | Q extends Queries = SyncQueries,
93 | > = {
94 | onRender?: (
95 | info: BaseRender & {
96 | snapshot: Snapshot
97 | replaceSnapshot: ReplaceSnapshot
98 | mergeSnapshot: MergeSnapshot
99 | },
100 | ) => void
101 | snapshotDOM?: boolean
102 | initialSnapshot?: Snapshot
103 | /**
104 | * This will skip renders during which no renders tracked by
105 | * `useTrackRenders` occured.
106 | */
107 | skipNonTrackingRenders?: boolean
108 | queries?: Q
109 | }
110 |
111 | export class WaitForRenderTimeoutError extends Error {
112 | constructor() {
113 | super('Exceeded timeout waiting for next render.')
114 | this.name = 'WaitForRenderTimeoutError'
115 | Object.setPrototypeOf(this, new.target.prototype)
116 | }
117 | }
118 |
119 | export function createRenderStream<
120 | Snapshot extends ValidSnapshot = void,
121 | Q extends Queries = SyncQueries,
122 | >({
123 | onRender,
124 | snapshotDOM = false,
125 | initialSnapshot,
126 | skipNonTrackingRenders,
127 | queries = syncQueries as any as Q,
128 | }: RenderStreamOptions = {}): RenderStreamWithRenderFn<
129 | Snapshot,
130 | Q
131 | > {
132 | // creating the object first and then assigning in all the properties
133 | // allows keeping the object instance for reference while the members are
134 | // created, which is important for the `markAssertable` function
135 | const stream = {} as any as RenderStreamWithRenderFn
136 |
137 | let nextRender: Promise> | undefined,
138 | resolveNextRender: ((render: Render) => void) | undefined,
139 | rejectNextRender: ((error: unknown) => void) | undefined
140 | function resetNextRender() {
141 | nextRender = undefined
142 | resolveNextRender = undefined
143 | rejectNextRender = undefined
144 | }
145 | const snapshotRef = {current: initialSnapshot}
146 | const replaceSnapshot: ReplaceSnapshot = snap => {
147 | if (typeof snap === 'function') {
148 | if (!initialSnapshot) {
149 | throw new Error(
150 | 'Cannot use a function to update the snapshot if no initial snapshot was provided.',
151 | )
152 | }
153 | snapshotRef.current = snap(
154 | typeof snapshotRef.current === 'object'
155 | ? // "cheap best effort" to prevent accidental mutation of the last snapshot
156 | {...snapshotRef.current}
157 | : snapshotRef.current!,
158 | )
159 | } else {
160 | snapshotRef.current = snap
161 | }
162 | }
163 |
164 | const mergeSnapshot: MergeSnapshot = partialSnapshot => {
165 | replaceSnapshot(snapshot => ({
166 | ...snapshot,
167 | ...(typeof partialSnapshot === 'function'
168 | ? partialSnapshot(snapshot)
169 | : partialSnapshot),
170 | }))
171 | }
172 |
173 | const renderStreamContext: RenderStreamContextValue = {
174 | renderedComponents: [],
175 | }
176 |
177 | const profilerOnRender: React.ProfilerOnRenderCallback = (
178 | id,
179 | phase,
180 | actualDuration,
181 | baseDuration,
182 | startTime,
183 | commitTime,
184 | ) => {
185 | if (
186 | skipNonTrackingRenders &&
187 | renderStreamContext.renderedComponents.length === 0
188 | ) {
189 | return
190 | }
191 |
192 | const renderBase = {
193 | id,
194 | phase,
195 | actualDuration,
196 | baseDuration,
197 | startTime,
198 | commitTime,
199 | count: stream.renders.length + 1,
200 | }
201 | try {
202 | /*
203 | * The `onRender` function could contain `expect` calls that throw
204 | * `JestAssertionError`s - but we are still inside of React, where errors
205 | * might be swallowed.
206 | * So we record them and re-throw them in `takeRender`
207 | * Additionally, we reject the `waitForNextRender` promise.
208 | */
209 | onRender?.({
210 | ...renderBase,
211 | replaceSnapshot,
212 | mergeSnapshot,
213 | snapshot: snapshotRef.current!,
214 | })
215 |
216 | const snapshot = snapshotRef.current as Snapshot
217 | const domSnapshot = snapshotDOM
218 | ? window.document.body.innerHTML
219 | : undefined
220 | const render = new RenderInstance(
221 | renderBase,
222 | snapshot,
223 | domSnapshot,
224 | renderStreamContext.renderedComponents,
225 | queries,
226 | )
227 | renderStreamContext.renderedComponents = []
228 | stream.renders.push(render)
229 | resolveNextRender?.(render)
230 | } catch (error) {
231 | stream.renders.push({
232 | phase: 'snapshotError',
233 | count: stream.renders.length,
234 | error,
235 | })
236 | rejectNextRender?.(error)
237 | } finally {
238 | resetNextRender()
239 | }
240 | }
241 |
242 | let iteratorPosition = 0
243 | function Wrapper({children}: {children: React.ReactNode}) {
244 | return (
245 |
246 |
247 | {children}
248 |
249 |
250 | )
251 | }
252 |
253 | const render: RenderWithoutActAsync = (async (
254 | ui: React.ReactNode,
255 | options?: RenderOptions,
256 | ) => {
257 | const ret = await renderWithoutAct(ui, {
258 | ...options,
259 | wrapper: props => {
260 | const ParentWrapper = options?.wrapper ?? React.Fragment
261 | return (
262 |
263 | {props.children}
264 |
265 | )
266 | },
267 | })
268 | if (stream.renders.length === 0) {
269 | await stream.waitForNextRender()
270 | }
271 | const origRerender = ret.rerender
272 | ret.rerender = async function rerender(rerenderUi: React.ReactNode) {
273 | const previousRenderCount = stream.renders.length
274 | try {
275 | return await origRerender(rerenderUi)
276 | } finally {
277 | // only wait for the next render if the rerender was not
278 | // synchronous (React 17)
279 | if (previousRenderCount === stream.renders.length) {
280 | await stream.waitForNextRender()
281 | }
282 | }
283 | }
284 | return ret
285 | }) as unknown as RenderWithoutActAsync // TODO
286 |
287 | Object.assign(stream, {
288 | replaceSnapshot,
289 | mergeSnapshot,
290 | renders: new Array<
291 | | Render
292 | | {phase: 'snapshotError'; count: number; error: unknown}
293 | >(),
294 | totalRenderCount() {
295 | return stream.renders.length
296 | },
297 | async peekRender(options: NextRenderOptions = {}) {
298 | try {
299 | if (iteratorPosition < stream.renders.length) {
300 | const peekedRender = stream.renders[iteratorPosition]
301 |
302 | if (peekedRender.phase === 'snapshotError') {
303 | throw peekedRender.error
304 | }
305 |
306 | return peekedRender
307 | }
308 | return await stream
309 | .waitForNextRender(options)
310 | .catch(rethrowWithCapturedStackTrace(stream.peekRender))
311 | } finally {
312 | /** drain microtask queue */
313 | await new Promise(resolve => {
314 | setTimeout(() => {
315 | resolve()
316 | }, 0)
317 | })
318 | }
319 | },
320 | takeRender: markAssertable(async function takeRender(
321 | options: NextRenderOptions = {},
322 | ) {
323 | let error: unknown
324 |
325 | try {
326 | return await stream.peekRender({
327 | ...options,
328 | })
329 | } catch (e) {
330 | if (e instanceof Object) {
331 | Error.captureStackTrace(e, stream.takeRender)
332 | }
333 | error = e
334 | throw e
335 | } finally {
336 | if (!(error && error instanceof WaitForRenderTimeoutError)) {
337 | iteratorPosition++
338 | }
339 | }
340 | }, stream),
341 | getCurrentRender() {
342 | // The "current" render should point at the same render that the most
343 | // recent `takeRender` call returned, so we need to get the "previous"
344 | // iterator position, otherwise `takeRender` advances the iterator
345 | // to the next render. This means we need to call `takeRender` at least
346 | // once before we can get a current render.
347 | const currentPosition = iteratorPosition - 1
348 |
349 | if (currentPosition < 0) {
350 | throw new Error(
351 | 'No current render available. You need to call `takeRender` before you can get the current render.',
352 | )
353 | }
354 |
355 | const currentRender = stream.renders[currentPosition]
356 |
357 | if (currentRender.phase === 'snapshotError') {
358 | throw currentRender.error
359 | }
360 | return currentRender
361 | },
362 | waitForNextRender({timeout = 1000}: NextRenderOptions = {}) {
363 | if (!nextRender) {
364 | nextRender = Promise.race>([
365 | new Promise>((resolve, reject) => {
366 | resolveNextRender = resolve
367 | rejectNextRender = reject
368 | }),
369 | new Promise>((_, reject) =>
370 | setTimeout(() => {
371 | const error = new WaitForRenderTimeoutError()
372 | Error.captureStackTrace(error, stream.waitForNextRender)
373 | reject(error)
374 | resetNextRender()
375 | }, timeout),
376 | ),
377 | ])
378 | }
379 | return nextRender
380 | },
381 | render,
382 | })
383 | return stream
384 | }
385 |
386 | function rethrowWithCapturedStackTrace(constructorOpt: Function | undefined) {
387 | return function catchFn(error: unknown) {
388 | if (error instanceof Object) {
389 | Error.captureStackTrace(error, constructorOpt)
390 | }
391 | throw error
392 | }
393 | }
394 |
--------------------------------------------------------------------------------
/src/renderStream/syncQueries.ts:
--------------------------------------------------------------------------------
1 | import {queries} from '@testing-library/dom'
2 |
3 | export type {Queries} from '@testing-library/dom'
4 |
5 | type OriginalQueries = typeof queries
6 |
7 | export type SyncQueries = {
8 | [K in keyof OriginalQueries as K extends `${'find'}${string}`
9 | ? never
10 | : K]: OriginalQueries[K]
11 | }
12 |
13 | export const syncQueries = Object.fromEntries(
14 | Object.entries(queries).filter(
15 | ([key]) => key.startsWith('get') || key.startsWith('query'),
16 | ),
17 | ) as any as SyncQueries
18 |
19 | export type BoundFunction = T extends (
20 | container: HTMLElement,
21 | ...args: infer P
22 | ) => infer R
23 | ? (...args: P) => R
24 | : never
25 |
26 | export type BoundSyncFunctions = Q extends typeof syncQueries
27 | ? {
28 | getByLabelText(
29 | ...args: Parameters>>
30 | ): ReturnType>
31 | getAllByLabelText(
32 | ...args: Parameters>>
33 | ): ReturnType>
34 | queryByLabelText(
35 | ...args: Parameters>>
36 | ): ReturnType>
37 | queryAllByLabelText(
38 | ...args: Parameters>>
39 | ): ReturnType>
40 | getByPlaceholderText(
41 | ...args: Parameters>>
42 | ): ReturnType>
43 | getAllByPlaceholderText(
44 | ...args: Parameters>>
45 | ): ReturnType>
46 | queryByPlaceholderText(
47 | ...args: Parameters>>
48 | ): ReturnType>
49 | queryAllByPlaceholderText(
50 | ...args: Parameters>>
51 | ): ReturnType>
52 | getByText(
53 | ...args: Parameters>>
54 | ): ReturnType>
55 | getAllByText(
56 | ...args: Parameters>>
57 | ): ReturnType>
58 | queryByText(
59 | ...args: Parameters>>
60 | ): ReturnType>
61 | queryAllByText(
62 | ...args: Parameters>>
63 | ): ReturnType>
64 | getByAltText(
65 | ...args: Parameters>>
66 | ): ReturnType>
67 | getAllByAltText(
68 | ...args: Parameters>>
69 | ): ReturnType>
70 | queryByAltText(
71 | ...args: Parameters>>
72 | ): ReturnType>
73 | queryAllByAltText(
74 | ...args: Parameters>>
75 | ): ReturnType>
76 | getByTitle(
77 | ...args: Parameters>>
78 | ): ReturnType>
79 | getAllByTitle(
80 | ...args: Parameters>>
81 | ): ReturnType>
82 | queryByTitle(
83 | ...args: Parameters>>
84 | ): ReturnType>
85 | queryAllByTitle(
86 | ...args: Parameters>>
87 | ): ReturnType>
88 | getByDisplayValue(
89 | ...args: Parameters>>
90 | ): ReturnType>
91 | getAllByDisplayValue(
92 | ...args: Parameters>>
93 | ): ReturnType>
94 | queryByDisplayValue(
95 | ...args: Parameters>>
96 | ): ReturnType>
97 | queryAllByDisplayValue(
98 | ...args: Parameters>>
99 | ): ReturnType>
100 | getByRole(
101 | ...args: Parameters>>
102 | ): ReturnType>
103 | getAllByRole(
104 | ...args: Parameters>>
105 | ): ReturnType>
106 | queryByRole(
107 | ...args: Parameters>>
108 | ): ReturnType>
109 | queryAllByRole(
110 | ...args: Parameters>>
111 | ): ReturnType>
112 | getByTestId(
113 | ...args: Parameters>>
114 | ): ReturnType>
115 | getAllByTestId(
116 | ...args: Parameters>>
117 | ): ReturnType>
118 | queryByTestId(
119 | ...args: Parameters>>
120 | ): ReturnType>
121 | queryAllByTestId(
122 | ...args: Parameters>>
123 | ): ReturnType>
124 | } & {
125 | [P in keyof Q]: BoundFunction
126 | }
127 | : {
128 | [P in keyof Q]: BoundFunction
129 | }
130 |
--------------------------------------------------------------------------------
/src/renderStream/useTrackRenders.ts:
--------------------------------------------------------------------------------
1 | import React from 'rehackt'
2 | import {useRenderStreamContext} from './context.js'
3 |
4 | function resolveR18HookOwner(): React.ComponentType | undefined {
5 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
6 | return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
7 | ?.ReactCurrentOwner?.current?.elementType
8 | }
9 |
10 | function resolveR19HookOwner(): React.ComponentType | undefined {
11 | /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
12 | return (
13 | React as any
14 | ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?.A?.getOwner()
15 | .elementType
16 | }
17 |
18 | export function useTrackRenders({name}: {name?: string} = {}) {
19 | const component = name ?? resolveR18HookOwner() ?? resolveR19HookOwner()
20 |
21 | if (!component) {
22 | throw new Error(
23 | 'useTrackRenders: Unable to determine component. Please ensure the hook is called inside a rendered component or provide a `name` option.',
24 | )
25 | }
26 |
27 | const ctx = useRenderStreamContext()
28 |
29 | if (!ctx) {
30 | throw new Error(
31 | 'useTrackRenders: A Render Stream must be created and rendered to track component renders',
32 | )
33 | }
34 |
35 | React.useLayoutEffect(() => {
36 | ctx.renderedComponents.unshift(component)
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/renderWithoutAct.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOMClient from 'react-dom/client'
2 | import * as ReactDOM from 'react-dom'
3 | import {type RenderOptions} from '@testing-library/react/pure.js'
4 | import {
5 | BoundFunction,
6 | getQueriesForElement,
7 | prettyDOM,
8 | prettyFormat,
9 | type Queries,
10 | } from '@testing-library/dom'
11 | import React from 'react'
12 | import {SyncQueries} from './renderStream/syncQueries.js'
13 | import {
14 | disableActEnvironment,
15 | DisableActEnvironmentOptions,
16 | } from './disableActEnvironment.js'
17 |
18 | // Ideally we'd just use a WeakMap where containers are keys and roots are values.
19 | // We use two variables so that we can bail out in constant time when we render with a new container (most common use case)
20 |
21 | const mountedContainers: Set = new Set()
22 | const mountedRootEntries: Array<{
23 | container: import('react-dom').Container
24 | root: ReturnType
25 | }> = []
26 |
27 | export type AsyncRenderResult<
28 | Q extends Queries = SyncQueries,
29 | Container extends ReactDOMClient.Container = HTMLElement,
30 | BaseElement extends ReactDOMClient.Container = Container,
31 | > = {
32 | container: Container
33 | baseElement: BaseElement
34 | debug: (
35 | baseElement?:
36 | | ReactDOMClient.Container
37 | | Array
38 | | undefined,
39 | maxLength?: number | undefined,
40 | options?: prettyFormat.OptionsReceived | undefined,
41 | ) => void
42 | rerender: (rerenderUi: React.ReactNode) => Promise
43 | unmount: () => void
44 | asFragment: () => DocumentFragment
45 | } & {[P in keyof Q]: BoundFunction}
46 |
47 | function renderRoot(
48 | ui: React.ReactNode,
49 | {
50 | baseElement,
51 | container,
52 | queries,
53 | wrapper: WrapperComponent,
54 | root,
55 | }: Pick, 'queries' | 'wrapper'> & {
56 | baseElement: ReactDOMClient.Container
57 | container: ReactDOMClient.Container
58 | root: ReturnType
59 | },
60 | ): AsyncRenderResult<{}, any, any> {
61 | root.render(
62 | WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui,
63 | )
64 |
65 | return {
66 | container,
67 | baseElement,
68 | debug: (el = baseElement, maxLength, options) =>
69 | Array.isArray(el)
70 | ? // eslint-disable-next-line no-console
71 | el.forEach(e =>
72 | console.log(prettyDOM(e as Element, maxLength, options)),
73 | )
74 | : // eslint-disable-next-line no-console,
75 | console.log(prettyDOM(el as Element, maxLength, options)),
76 | unmount: () => {
77 | root.unmount()
78 | },
79 | rerender: async rerenderUi => {
80 | renderRoot(rerenderUi, {
81 | container,
82 | baseElement,
83 | root,
84 | wrapper: WrapperComponent,
85 | })
86 | // Intentionally do not return anything to avoid unnecessarily complicating the API.
87 | // folks can use all the same utilities we return in the first place that are bound to the container
88 | },
89 | asFragment: () => {
90 | /* istanbul ignore else (old jsdom limitation) */
91 | if (typeof document.createRange === 'function') {
92 | return document
93 | .createRange()
94 | .createContextualFragment((container as HTMLElement).innerHTML)
95 | } else {
96 | const template = document.createElement('template')
97 | template.innerHTML = (container as HTMLElement).innerHTML
98 | return template.content
99 | }
100 | },
101 | ...getQueriesForElement(baseElement as HTMLElement, queries),
102 | }
103 | }
104 |
105 | export type RenderWithoutActAsync = {
106 | <
107 | Q extends Queries = SyncQueries,
108 | Container extends ReactDOMClient.Container = HTMLElement,
109 | BaseElement extends ReactDOMClient.Container = Container,
110 | >(
111 | this: any,
112 | ui: React.ReactNode,
113 | options: Pick<
114 | RenderOptions,
115 | 'container' | 'baseElement' | 'queries' | 'wrapper'
116 | >,
117 | ): Promise>
118 | (
119 | this: any,
120 | ui: React.ReactNode,
121 | options?:
122 | | Pick
123 | | undefined,
124 | ): Promise<
125 | AsyncRenderResult<
126 | SyncQueries,
127 | ReactDOMClient.Container,
128 | ReactDOMClient.Container
129 | >
130 | >
131 | }
132 |
133 | export const renderWithoutAct =
134 | _renderWithoutAct as unknown as RenderWithoutActAsync
135 |
136 | async function _renderWithoutAct(
137 | ui: React.ReactNode,
138 | {
139 | container,
140 | baseElement = container,
141 | queries,
142 | wrapper,
143 | }: Pick<
144 | RenderOptions,
145 | 'container' | 'baseElement' | 'wrapper' | 'queries'
146 | > = {},
147 | ): Promise> {
148 | if (!baseElement) {
149 | // default to document.body instead of documentElement to avoid output of potentially-large
150 | // head elements (such as JSS style blocks) in debug output
151 | baseElement = document.body
152 | }
153 | if (!container) {
154 | container = baseElement.appendChild(document.createElement('div'))
155 | }
156 |
157 | let root: ReturnType
158 | // 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.
159 | if (!mountedContainers.has(container)) {
160 | root = (
161 | ReactDOM.version.startsWith('16') || ReactDOM.version.startsWith('17')
162 | ? createLegacyRoot
163 | : createConcurrentRoot
164 | )(container)
165 | mountedRootEntries.push({container, root})
166 | // we'll add it to the mounted containers regardless of whether it's actually
167 | // added to document.body so the cleanup method works regardless of whether
168 | // they're passing us a custom container or not.
169 | mountedContainers.add(container)
170 | } else {
171 | mountedRootEntries.forEach(rootEntry => {
172 | // Else is unreachable since `mountedContainers` has the `container`.
173 | // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries`
174 | /* istanbul ignore else */
175 | if (rootEntry.container === container) {
176 | root = rootEntry.root
177 | }
178 | })
179 | }
180 |
181 | return renderRoot(ui, {
182 | baseElement,
183 | container,
184 | queries,
185 | wrapper,
186 | root: root!,
187 | })
188 | }
189 |
190 | function createLegacyRoot(container: ReactDOMClient.Container) {
191 | return {
192 | render(element: React.ReactNode) {
193 | ReactDOM.render(element as unknown as React.ReactElement, container)
194 | },
195 | unmount() {
196 | ReactDOM.unmountComponentAtNode(container)
197 | },
198 | }
199 | }
200 |
201 | function createConcurrentRoot(container: ReactDOMClient.Container) {
202 | const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean}
203 | if (anyThis.IS_REACT_ACT_ENVIRONMENT) {
204 | throw new Error(`Tried to create a React root for a render stream inside a React act environment.
205 | This is not supported. Please use \`disableActEnvironment\` to disable the act environment for this test.`)
206 | }
207 | const root = ReactDOMClient.createRoot(container)
208 |
209 | return {
210 | render(element: React.ReactNode) {
211 | if (anyThis.IS_REACT_ACT_ENVIRONMENT) {
212 | throw new Error(`Tried to render a render stream inside a React act environment.
213 | This is not supported. Please use \`disableActEnvironment\` to disable the act environment for this test.`)
214 | }
215 | root.render(element)
216 | },
217 | unmount() {
218 | root.unmount()
219 | },
220 | }
221 | }
222 |
223 | export function cleanup() {
224 | if (!mountedRootEntries.length) {
225 | // nothing to clean up
226 | return
227 | }
228 |
229 | // there is a good chance this happens outside of a test, where the user
230 | // has no control over enabling or disabling the React Act environment,
231 | // so we do it for them here.
232 |
233 | const disabledAct = disableActEnvironment({
234 | preventModification: false,
235 | adjustTestingLibConfig: false,
236 | } satisfies /* ensure that all possible options are passed here in case we add more in the future */ Required)
237 | try {
238 | for (const {root, container} of mountedRootEntries) {
239 | root.unmount()
240 |
241 | if (container.parentNode === document.body) {
242 | document.body.removeChild(container)
243 | }
244 | }
245 | mountedRootEntries.length = 0
246 | mountedContainers.clear()
247 | } finally {
248 | disabledAct.cleanup()
249 | }
250 | }
251 |
--------------------------------------------------------------------------------
/tests/polyfill.js:
--------------------------------------------------------------------------------
1 | import {TextEncoder, TextDecoder} from 'util'
2 |
3 | global.TextEncoder = TextEncoder
4 | global.TextDecoder = TextDecoder
5 |
6 | Symbol.dispose = Symbol('Symbol.dispose')
7 |
--------------------------------------------------------------------------------
/tests/setup-env.js:
--------------------------------------------------------------------------------
1 | import './polyfill.js'
2 |
3 | Object.defineProperty(global, 'IS_REACT_ACT_ENVIRONMENT', {
4 | get() {
5 | return false
6 | },
7 | set(value) {
8 | if (!!value) {
9 | throw new Error(
10 | 'Cannot set IS_REACT_ACT_ENVIRONMENT to true, this probably pulled in some RTL dependency?',
11 | )
12 | }
13 | },
14 | configurable: true,
15 | })
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/recommended/tsconfig.json",
3 | "compilerOptions": {
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "target": "esnext",
7 | "rootDir": ".",
8 | "outDir": "dist",
9 | "declaration": true,
10 | "sourceMap": true,
11 | "jsx": "react",
12 | "allowJs": true,
13 | "declarationMap": true,
14 | "types": [
15 | "react",
16 | "node"
17 | ],
18 | "esModuleInterop": true,
19 | "allowSyntheticDefaultImports": true,
20 | "paths": {
21 | "@testing-library/react-render-stream": [
22 | "./src/index.ts"
23 | ],
24 | "@testing-library/react-render-stream/pure": [
25 | "./src/pure.ts"
26 | ]
27 | }
28 | },
29 | "include": [
30 | "src",
31 | "tests"
32 | ]
33 | }
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from 'tsup'
2 |
3 | export default defineConfig({
4 | entry: {
5 | index: 'src/index.ts',
6 | pure: 'src/pure.ts',
7 | expect: 'src/expect/index.ts',
8 | },
9 | splitting: false,
10 | sourcemap: true,
11 | clean: true,
12 | dts: true,
13 | format: ['cjs', 'esm'],
14 | target: ['node20'],
15 | external: [/^@testing-library\/react-render-stream/],
16 | })
17 |
--------------------------------------------------------------------------------