Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support async act #10

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,16 @@
},
"devDependencies": {
"@antfu/eslint-config": "^2.24.1",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.3",
"@vitest/browser": "^2.1.0",
"bumpp": "^9.4.2",
"changelogithub": "^0.13.9",
"eslint": "^9.8.0",
"playwright": "^1.46.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.2.4",
"tsx": "^4.17.0",
"typescript": "^5.5.4",
Expand Down
87 changes: 35 additions & 52 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 14 additions & 14 deletions src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import ReactDOMClient from 'react-dom/client'
// we call act only when rendering to flush any possible effects
// usually the async nature of Vitest browser mode ensures consistency,
// but rendering is sync and controlled by React directly
function act(cb: () => unknown) {
async function act(cb: () => unknown) {
// @ts-expect-error unstable_act is not typed, but exported
const _act = React.act || React.unstable_act
if (typeof _act !== 'function') {
cb()
}
else {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
_act(cb)
await _act(cb)
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false
}
}
Expand All @@ -28,8 +28,8 @@ export interface RenderResult extends LocatorSelectors {
maxLength?: number,
options?: PrettyDOMOptions
) => void
unmount: () => void
rerender: (ui: React.ReactNode) => void
unmount: () => Promise<void>
rerender: (ui: React.ReactNode) => Promise<void>
asFragment: () => DocumentFragment
}

Expand All @@ -47,10 +47,10 @@ const mountedRootEntries: {
root: ReturnType<typeof createConcurrentRoot>
}[] = []

export function render(
export async function render(
ui: React.ReactNode,
{ container, baseElement, wrapper: WrapperComponent }: ComponentRenderOptions = {},
): RenderResult {
): Promise<RenderResult> {
if (!baseElement) {
// default to document.body instead of documentElement to avoid output of potentially-large
// head elements (such as JSS style blocks) in debug output
Expand Down Expand Up @@ -83,7 +83,7 @@ export function render(
})
}

act(() => {
await act(() => {
root!.render(
strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
)
Expand All @@ -93,13 +93,13 @@ export function render(
container,
baseElement,
debug: (el, maxLength, options) => debug(el, maxLength, options),
unmount: () => {
act(() => {
unmount: async () => {
await act(() => {
root.unmount()
})
},
rerender: (newUi: React.ReactNode) => {
act(() => {
rerender: async (newUi: React.ReactNode) => {
await act(() => {
root.render(
strictModeIfNeeded(wrapUiIfNeeded(newUi, WrapperComponent)),
)
Expand All @@ -112,9 +112,9 @@ export function render(
}
}

export function cleanup(): void {
mountedRootEntries.forEach(({ root, container }) => {
act(() => {
export async function cleanup(): Promise<void> {
mountedRootEntries.forEach(async ({ root, container }) => {
await act(() => {
root.unmount()
})
if (container.parentNode === document.body) {
Expand Down
8 changes: 6 additions & 2 deletions test/fixtures/HelloWorld.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export function HelloWorld(): React.ReactElement {
return <div>Hello World</div>
export function HelloWorld({
name = 'World',
}: {
name?: string
}): React.ReactElement {
return <div>{`Hello ${name}`}</div>
}
19 changes: 19 additions & 0 deletions test/fixtures/SuspendedHelloWorld.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { use } from 'react'

let fakeCacheLoaded = false
const fakeCacheLoadPromise = new Promise<void>((resolve) => {
setTimeout(() => {
fakeCacheLoaded = true
resolve()
}, 100)
})

export function SuspendedHelloWorld({ name }: { name: string }): React.ReactElement {
if (!fakeCacheLoaded) {
use(fakeCacheLoadPromise)
}

return (
<div>{`Hello ${name}`}</div>
)
}
16 changes: 14 additions & 2 deletions test/render.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import { Suspense } from 'react'
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import { render } from '../src/index'
import { HelloWorld } from './fixtures/HelloWorld'
import { Counter } from './fixtures/Counter'
import { SuspendedHelloWorld } from './fixtures/SuspendedHelloWorld'

test('renders simple component', async () => {
const screen = render(<HelloWorld />)
const screen = await render(<HelloWorld />)
await expect.element(page.getByText('Hello World')).toBeVisible()
expect(screen.container).toMatchSnapshot()
})

test('renders counter', async () => {
const screen = render(<Counter initialCount={1} />)
const screen = await render(<Counter initialCount={1} />)

await expect.element(screen.getByText('Count is 1')).toBeVisible()
await screen.getByRole('button', { name: 'Increment' }).click()
await expect.element(screen.getByText('Count is 2')).toBeVisible()
})

test('waits for suspended boundaries', async () => {
const { getByText } = await render(<SuspendedHelloWorld name="Vitest" />, {
wrapper: ({ children }) => (
<Suspense fallback={<div>Suspended!</div>}>{children}</Suspense>
),
})
await expect.element(getByText('Suspended!')).toBeInTheDocument()

Check failure on line 29 in test/render.test.tsx

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 20)

test/render.test.tsx > waits for suspended boundaries

Error: Matcher did not succeed in 1000ms ❯ test/render.test.tsx:29:48 Caused by: Caused by: VitestBrowserElementError: Cannot find element with locator: locator('body').getByText('Suspended!') <body> <div> <div> Hello Vitest </div> </div> </body>

Check failure on line 29 in test/render.test.tsx

View workflow job for this annotation

GitHub Actions / test (macos-14, 20)

test/render.test.tsx > waits for suspended boundaries

Error: Matcher did not succeed in 1000ms ❯ test/render.test.tsx:29:48 Caused by: Caused by: VitestBrowserElementError: Cannot find element with locator: locator('body').getByText('Suspended!') <body> <div> <div> Hello Vitest </div> </div> </body>

Check failure on line 29 in test/render.test.tsx

View workflow job for this annotation

GitHub Actions / test (windows-latest, 20)

test/render.test.tsx > waits for suspended boundaries

Error: Matcher did not succeed in 1000ms ❯ test/render.test.tsx:29:48 Caused by: Caused by: VitestBrowserElementError: Cannot find element with locator: locator('body').getByText('Suspended!') <body> <div> <div> Hello Vitest </div> </div> </body>
await expect.element(getByText('Hello Vitest')).toBeInTheDocument()
})
Loading