Skip to content

Commit dd89901

Browse files
cloudkiteMatanBobi
andauthored
feat!: support async act v2 (#22)
Co-authored-by: Matan Borenkraout <matanbobi@gmail.com>
1 parent 524831e commit dd89901

File tree

9 files changed

+956
-943
lines changed

9 files changed

+956
-943
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { render } from 'vitest-browser-react'
1111
import { expect, test } from 'vitest'
1212

1313
test('counter button increments the count', async () => {
14-
const screen = render(<Component count={1} />)
14+
const screen = await render(<Component count={1} />)
1515

1616
await screen.getByRole('button', { name: 'Increment' }).click()
1717

@@ -29,9 +29,9 @@ import { renderHook } from 'vitest-browser-react'
2929
import { expect, test } from 'vitest'
3030

3131
test('should increment counter', async () => {
32-
const { result, act } = renderHook(() => useCounter())
32+
const { result, act } = await renderHook(() => useCounter())
3333

34-
act(() => {
34+
await act(() => {
3535
result.current.increment()
3636
})
3737

@@ -64,9 +64,9 @@ import 'vitest-browser-react'
6464
import { page } from '@vitest/browser/context'
6565

6666
test('counter button increments the count', async () => {
67-
const screen = page.render(<Component count={1} />)
67+
const screen = await page.render(<Component count={1} />)
6868

69-
screen.cleanup()
69+
await screen.cleanup()
7070
})
7171
```
7272

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,17 @@
6969
},
7070
"devDependencies": {
7171
"@antfu/eslint-config": "^2.24.1",
72-
"@types/react": "^18.0.0",
73-
"@types/react-dom": "^18.3.0",
72+
"@types/react": "^19.0.0",
73+
"@types/react-dom": "^19.0.0",
7474
"@vitejs/plugin-react": "^4.3.3",
7575
"@vitest/browser": "^3.1.0",
7676
"bumpp": "^9.4.2",
7777
"changelogithub": "^0.13.9",
7878
"eslint": "^9.8.0",
7979
"playwright": "^1.46.0",
80-
"react": "^18.0.0",
80+
"react": "^19.0.0",
8181
"react-aria-components": "^1.10.1",
82-
"react-dom": "^18.0.0",
82+
"react-dom": "^19.0.0",
8383
"tsup": "^8.2.4",
8484
"tsx": "^4.17.0",
8585
"typescript": "^5.5.4",

pnpm-lock.yaml

Lines changed: 884 additions & 901 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ page.extend({
1010
[Symbol.for('vitest:component-cleanup')]: cleanup,
1111
})
1212

13-
beforeEach(() => {
14-
cleanup()
13+
beforeEach(async () => {
14+
await cleanup()
1515
})
1616

1717
declare module '@vitest/browser/context' {

src/pure.tsx

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import ReactDOMClient from 'react-dom/client'
77
// we call act only when rendering to flush any possible effects
88
// usually the async nature of Vitest browser mode ensures consistency,
99
// but rendering is sync and controlled by React directly
10-
function act(cb: () => unknown) {
10+
async function act(cb: () => unknown) {
1111
// @ts-expect-error unstable_act is not typed, but exported
1212
const _act = React.act || React.unstable_act
1313
if (typeof _act !== 'function') {
@@ -16,7 +16,7 @@ function act(cb: () => unknown) {
1616
else {
1717
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
1818
try {
19-
_act(cb)
19+
await _act(cb)
2020
}
2121
finally {
2222
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false
@@ -32,8 +32,8 @@ export interface RenderResult extends LocatorSelectors {
3232
maxLength?: number,
3333
options?: PrettyDOMOptions
3434
) => void
35-
unmount: () => void
36-
rerender: (ui: React.ReactNode) => void
35+
unmount: () => Promise<void>
36+
rerender: (ui: React.ReactNode) => Promise<void>
3737
asFragment: () => DocumentFragment
3838
}
3939

@@ -51,10 +51,10 @@ const mountedRootEntries: {
5151
root: ReturnType<typeof createConcurrentRoot>
5252
}[] = []
5353

54-
export function render(
54+
export async function render(
5555
ui: React.ReactNode,
5656
{ container, baseElement, wrapper: WrapperComponent }: ComponentRenderOptions = {},
57-
): RenderResult {
57+
): Promise<RenderResult> {
5858
if (!baseElement) {
5959
// default to document.body instead of documentElement to avoid output of potentially-large
6060
// head elements (such as JSS style blocks) in debug output
@@ -87,7 +87,7 @@ export function render(
8787
})
8888
}
8989

90-
act(() => {
90+
await act(() => {
9191
root!.render(
9292
strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
9393
)
@@ -97,13 +97,13 @@ export function render(
9797
container,
9898
baseElement,
9999
debug: (el, maxLength, options) => debug(el, maxLength, options),
100-
unmount: () => {
101-
act(() => {
100+
unmount: async () => {
101+
await act(() => {
102102
root.unmount()
103103
})
104104
},
105-
rerender: (newUi: React.ReactNode) => {
106-
act(() => {
105+
rerender: async (newUi: React.ReactNode) => {
106+
await act(() => {
107107
root.render(
108108
strictModeIfNeeded(wrapUiIfNeeded(newUi, WrapperComponent)),
109109
)
@@ -128,7 +128,7 @@ export interface RenderHookResult<Result, Props> {
128128
/**
129129
* Triggers a re-render. The props will be passed to your renderHook callback.
130130
*/
131-
rerender: (props?: Props) => void
131+
rerender: (props?: Props) => Promise<void>
132132
/**
133133
* This is a stable reference to the latest value returned by your renderHook
134134
* callback
@@ -143,14 +143,14 @@ export interface RenderHookResult<Result, Props> {
143143
* Unmounts the test component. This is useful for when you need to test
144144
* any cleanup your useEffects have.
145145
*/
146-
unmount: () => void
146+
unmount: () => Promise<void>
147147
/**
148148
* A test helper to apply pending React updates before making assertions.
149149
*/
150-
act: (callback: () => unknown) => void
150+
act: (callback: () => unknown) => Promise<void>
151151
}
152152

153-
export function renderHook<Props, Result>(renderCallback: (initialProps?: Props) => Result, options: RenderHookOptions<Props> = {}): RenderHookResult<Result, Props> {
153+
export async function renderHook<Props, Result>(renderCallback: (initialProps?: Props) => Result, options: RenderHookOptions<Props> = {}): Promise<RenderHookResult<Result, Props>> {
154154
const { initialProps, ...renderOptions } = options
155155

156156
const result = React.createRef<Result>() as unknown as { current: Result }
@@ -165,7 +165,7 @@ export function renderHook<Props, Result>(renderCallback: (initialProps?: Props)
165165
return null
166166
}
167167

168-
const { rerender: baseRerender, unmount } = render(
168+
const { rerender: baseRerender, unmount } = await render(
169169
<TestComponent renderCallbackProps={initialProps} />,
170170
renderOptions,
171171
)
@@ -179,15 +179,15 @@ export function renderHook<Props, Result>(renderCallback: (initialProps?: Props)
179179
return { result, rerender, unmount, act }
180180
}
181181

182-
export function cleanup(): void {
183-
mountedRootEntries.forEach(({ root, container }) => {
184-
act(() => {
182+
export async function cleanup(): Promise<void> {
183+
for (const { root, container } of mountedRootEntries) {
184+
await act(() => {
185185
root.unmount()
186186
})
187187
if (container.parentNode === document.body) {
188188
document.body.removeChild(container)
189189
}
190-
})
190+
}
191191
mountedRootEntries.length = 0
192192
mountedContainers.clear()
193193
}

test/fixtures/HelloWorld.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export function HelloWorld(): React.ReactElement {
2-
return <div>Hello World</div>
1+
export function HelloWorld({ name = 'World' }: { name?: string }): React.ReactElement {
2+
return <div>{`Hello ${name}`}</div>
33
}

test/fixtures/SuspendedHelloWorld.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { use } from 'react'
2+
3+
const fakeCacheLoadPromise = new Promise<void>((resolve) => {
4+
setTimeout(() => resolve(), 100)
5+
})
6+
7+
export function SuspendedHelloWorld({ name }: { name: string }): React.ReactElement {
8+
use(fakeCacheLoadPromise)
9+
10+
return <div>{`Hello ${name}`}</div>
11+
}

test/render-hook.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ import React from 'react'
44
import { renderHook } from '../src/index'
55
import { useCounter } from './fixtures/useCounter'
66

7-
test('should increment counter', () => {
8-
const { result, act } = renderHook(() => useCounter())
7+
test('should increment counter', async () => {
8+
const { result, act } = await renderHook(() => useCounter())
99

10-
act(() => {
10+
await act(() => {
1111
result.current.increment()
1212
})
1313

1414
expect(result.current.count).toBe(1)
1515
})
1616

17-
test('allows rerendering', () => {
18-
const { result, rerender } = renderHook(
17+
test('allows rerendering', async () => {
18+
const { result, rerender } = await renderHook(
1919
(initialProps) => {
2020
const [left, setLeft] = React.useState('left')
2121
const [right, setRight] = React.useState('right')
@@ -47,7 +47,7 @@ test('allows wrapper components', async () => {
4747
function Wrapper({ children }: PropsWithChildren) {
4848
return <Context.Provider value="provided">{children}</Context.Provider>
4949
}
50-
const { result } = renderHook(
50+
const { result } = await renderHook(
5151
() => {
5252
return React.useContext(Context)
5353
},

test/render.test.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { expect, test, vi } from 'vitest'
22
import { page, userEvent } from '@vitest/browser/context'
33
import { Button } from 'react-aria-components'
4+
import { Suspense } from 'react'
45
import { render } from '../src/index'
56
import { HelloWorld } from './fixtures/HelloWorld'
67
import { Counter } from './fixtures/Counter'
8+
import { SuspendedHelloWorld } from './fixtures/SuspendedHelloWorld'
79

810
test('renders simple component', async () => {
9-
const screen = render(<HelloWorld />)
11+
const screen = await render(<HelloWorld />)
1012
await expect.element(page.getByText('Hello World')).toBeVisible()
1113
expect(screen.container).toMatchSnapshot()
1214
})
1315

1416
test('renders counter', async () => {
15-
const screen = render(<Counter initialCount={1} />)
17+
const screen = await render(<Counter initialCount={1} />)
1618

1719
await expect.element(screen.getByText('Count is 1')).toBeVisible()
1820
await screen.getByRole('button', { name: 'Increment' }).click()
@@ -21,8 +23,25 @@ test('renders counter', async () => {
2123

2224
test('should fire the onPress/onClick handler', async () => {
2325
const handler = vi.fn()
24-
const screen = page.render(<Button onPress={handler}>Button</Button>)
26+
const screen = await page.render(<Button onPress={handler}>Button</Button>)
2527
await userEvent.click(screen.getByRole('button'))
2628
// await screen.getByRole('button').click()
2729
expect(handler).toHaveBeenCalled()
2830
})
31+
32+
test('waits for suspended boundaries', async ({ onTestFinished }) => {
33+
vi.useFakeTimers()
34+
onTestFinished(() => {
35+
vi.useRealTimers()
36+
})
37+
38+
const result = render(<SuspendedHelloWorld name="Vitest" />, {
39+
wrapper: ({ children }) => (
40+
<Suspense fallback={<div>Suspended!</div>}>{children}</Suspense>
41+
),
42+
})
43+
await expect.element(page.getByText('Suspended!')).toBeInTheDocument()
44+
vi.runAllTimers()
45+
await result
46+
await expect.element(page.getByText('Hello Vitest')).toBeInTheDocument()
47+
})

0 commit comments

Comments
 (0)