Skip to content

Commit

Permalink
make render async to wait for first render
Browse files Browse the repository at this point in the history
  • Loading branch information
phryneas committed Nov 27, 2024
1 parent e8616b3 commit 5ff5420
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 59 deletions.
12 changes: 6 additions & 6 deletions src/__tests__/renderHookToSnapshotStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ function useRerenderEvents(initialValue: unknown) {
}

test('basic functionality', async () => {
const {takeSnapshot} = renderHookToSnapshotStream(useRerenderEvents, {
const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, {
initialProps: 'initial',
})
testEvents.emit('rerenderWithValue', 'value')
await Promise.resolve()
testEvents.emit('rerenderWithValue', 'value2')
{
const snapshot = await takeSnapshot()
expect(snapshot).toBe('initial')
}
testEvents.emit('rerenderWithValue', 'value')
await Promise.resolve()
testEvents.emit('rerenderWithValue', 'value2')
{
const snapshot = await takeSnapshot()
expect(snapshot).toBe('value')
Expand All @@ -62,15 +62,15 @@ test.each<[type: string, initialValue: unknown, ...nextValues: unknown[]]>([
['null/undefined', null, undefined, null],
['undefined/null', undefined, null, undefined],
])('works with %s', async (_, initialValue, ...nextValues) => {
const {takeSnapshot} = renderHookToSnapshotStream(useRerenderEvents, {
const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, {
initialProps: initialValue,
})
expect(await takeSnapshot()).toBe(initialValue)
for (const nextValue of nextValues) {
testEvents.emit('rerenderWithValue', nextValue)
// allow for a render to happen
await Promise.resolve()
}
expect(await takeSnapshot()).toBe(initialValue)
for (const nextValue of nextValues) {
expect(await takeSnapshot()).toBe(nextValue)
}
Expand Down
15 changes: 10 additions & 5 deletions src/__tests__/renderToRenderStream.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import {describe, test, expect} from '@jest/globals'
import {renderToRenderStream} from '@testing-library/react-render-stream'
import {userEvent} from '@testing-library/user-event'
import {
renderToRenderStream,
userEventWithoutAct,
} from '@testing-library/react-render-stream'
import {userEvent as baseUserEvent} from '@testing-library/user-event'
import * as React from 'react'

// @ts-expect-error this is not defined anywhere
globalThis.IS_REACT_ACT_ENVIRONMENT = false

const userEvent = userEventWithoutAct(baseUserEvent)

function CounterForm({
value,
onIncrement,
Expand Down Expand Up @@ -43,14 +48,14 @@ describe('snapshotDOM', () => {
},
)
const utils = await renderResultPromise
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
{
const {withinDOM} = await takeRender()
const input = withinDOM().getByLabelText<HTMLInputElement>('Value')
expect(input.value).toBe('0')
}
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
{
const {withinDOM} = await takeRender()
const input = withinDOM().getByLabelText<HTMLInputElement>('Value')
Expand Down
1 change: 1 addition & 0 deletions src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export type {SnapshotStream} from './renderHookToSnapshotStream.js'
export type {Assertable} from './assertable.js'

export {renderWithoutAct, cleanup} from './renderStream/renderWithoutAct.js'
export {userEventWithoutAct} from './useWithoutAct.js'
6 changes: 3 additions & 3 deletions src/renderHookToSnapshotStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,18 @@ export interface SnapshotStream<Snapshot, Props> extends Assertable {
unmount: () => void
}

export function renderHookToSnapshotStream<ReturnValue, Props>(
export async function renderHookToSnapshotStream<ReturnValue, Props>(
renderCallback: (props: Props) => ReturnValue,
{initialProps, ...renderOptions}: RenderHookOptions<Props> = {},
): SnapshotStream<ReturnValue, Props> {
): Promise<SnapshotStream<ReturnValue, Props>> {
const {render, ...stream} = createRenderStream<{value: ReturnValue}, never>()

const HookComponent: React.FC<{arg: Props}> = props => {
stream.replaceSnapshot({value: renderCallback(props.arg)})
return null
}

const {rerender: baseRerender, unmount} = render(
const {rerender: baseRerender, unmount} = await render(
<HookComponent arg={initialProps!} />,
renderOptions,
)
Expand Down
70 changes: 30 additions & 40 deletions src/renderStream/__tests__/createRenderStream.test.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import {jest, describe, test, expect} from '@jest/globals'
import {createRenderStream} from '@testing-library/react-render-stream'
//import {userEvent} from '@testing-library/user-event'
import {
createRenderStream,
userEventWithoutAct,
} from '@testing-library/react-render-stream'
import {userEvent as baseUserEvent} from '@testing-library/user-event'
import * as React from 'react'
import {ErrorBoundary} from 'react-error-boundary'
import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js'

// @ts-expect-error this is not defined anywhere
globalThis.IS_REACT_ACT_ENVIRONMENT = false

async function click(element: HTMLElement) {
const opts = {bubbles: true, cancelable: true, buttons: 1}
element.dispatchEvent(new Event('mousedown', opts))
await new Promise(r => setTimeout(r, 50))
element.dispatchEvent(new Event('mouseup', opts))
element.dispatchEvent(new Event('click', opts))
}
const userEvent = userEventWithoutAct(baseUserEvent)

function CounterForm({
value,
Expand Down Expand Up @@ -49,15 +43,15 @@ describe('snapshotDOM', () => {
const {takeRender, render} = createRenderStream({
snapshotDOM: true,
})
const utils = render(<Counter />)
const utils = await render(<Counter />)
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
{
const {withinDOM} = await takeRender()
const input = withinDOM().getByLabelText<HTMLInputElement>('Value')
expect(input.value).toBe('0')
}
const incrementButton = utils.getByText('Increment') as HTMLElement // TODO
await click(incrementButton)
await click(incrementButton)
{
const {withinDOM} = await takeRender()
// a one-off to test that `queryBy` works and accepts a type argument
Expand All @@ -82,12 +76,12 @@ describe('snapshotDOM', () => {
const {takeRender, render} = createRenderStream({
snapshotDOM: true,
})
render(<Counter />)
await render(<Counter />)
{
const {withinDOM} = await takeRender()
const snapshotIncrementButton = withinDOM().getByText('Increment')
try {
await click(snapshotIncrementButton)
await userEvent.click(snapshotIncrementButton)
} catch (error) {
expect(error).toMatchInlineSnapshot(`
[Error: Uncaught [Error:
Expand All @@ -114,7 +108,7 @@ describe('snapshotDOM', () => {
snapshotDOM: true,
queries,
})
render(<Component />)
await render(<Component />)

const {withinDOM} = await takeRender()
expect(withinDOM().foo()).toBe(null)
Expand All @@ -140,14 +134,14 @@ describe('replaceSnapshot', () => {
const {takeRender, replaceSnapshot, render} = createRenderStream<{
value: number
}>()
const utils = render(<Counter />)
const utils = await render(<Counter />)
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
{
const {snapshot} = await takeRender()
expect(snapshot).toEqual({value: 0})
}
const incrementButton = utils.getByText('Increment') as HTMLElement // TODO
await click(incrementButton)
await click(incrementButton)
{
const {snapshot} = await takeRender()
expect(snapshot).toEqual({value: 1})
Expand All @@ -170,15 +164,14 @@ describe('replaceSnapshot', () => {
const {takeRender, replaceSnapshot, render} = createRenderStream({
initialSnapshot: {unrelatedValue: 'unrelated', value: -1},
})
const utils = render(<Counter />)
const utils = await render(<Counter />)
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
{
const {snapshot} = await takeRender()
expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 0})
}

const incrementButton = utils.getByText('Increment') as HTMLElement // TODO
await click(incrementButton)
await click(incrementButton)
{
const {snapshot} = await takeRender()
expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 1})
Expand All @@ -204,7 +197,7 @@ describe('replaceSnapshot', () => {

const spy = jest.spyOn(console, 'error')
spy.mockImplementation(() => {})
render(
await render(
<ErrorBoundary
fallbackRender={({error}) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
Expand All @@ -215,8 +208,6 @@ describe('replaceSnapshot', () => {
<Counter />
</ErrorBoundary>,
)
await new Promise(r => setTimeout(r, 10))

spy.mockRestore()

expect(caughtError!).toMatchInlineSnapshot(
Expand Down Expand Up @@ -244,12 +235,11 @@ describe('onRender', () => {
expect(info.count).toBe(info.snapshot.value + 1)
},
})
const utils = render(<Counter />)
const utils = await render(<Counter />)
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
await takeRender()

const incrementButton = utils.getByText('Increment') as HTMLElement // TODO
await click(incrementButton)
await click(incrementButton)
await takeRender()
await takeRender()
})
Expand All @@ -268,11 +258,11 @@ describe('onRender', () => {
},
})

const utils = render(<Counter />)
const utils = await render(<Counter />)
const incrementButton = utils.getByText('Increment')
await userEvent.click(incrementButton)
await userEvent.click(incrementButton)
await takeRender()
const incrementButton = utils.getByText('Increment') as HTMLElement // TODO
await click(incrementButton)
await click(incrementButton)
const error = await getExpectErrorMessage(takeRender())

expect(error).toMatchInlineSnapshot(`
Expand Down
14 changes: 9 additions & 5 deletions src/renderStream/createRenderStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {type RenderStreamContextValue} from './context.js'
import {RenderStreamContextProvider} from './context.js'
import {disableActWarnings} from './disableActWarnings.js'
import {syncQueries, type Queries, type SyncQueries} from './syncQueries.js'
import {renderWithoutAct} from './renderWithoutAct.js'
import {renderWithoutAct, RenderWithoutActAsync} from './renderWithoutAct.js'

export type ValidSnapshot =
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
Expand Down Expand Up @@ -82,7 +82,7 @@ export interface RenderStreamWithRenderFn<
Snapshot extends ValidSnapshot,
Q extends Queries = SyncQueries,
> extends RenderStream<Snapshot, Q> {
render: typeof renderWithoutAct
render: RenderWithoutActAsync
}

export type RenderStreamOptions<
Expand Down Expand Up @@ -248,11 +248,11 @@ export function createRenderStream<
)
}

const render = ((
const render: RenderWithoutActAsync = (async (
ui: React.ReactNode,
options?: RenderOptions<any, any, any>,
) => {
return renderWithoutAct(ui, {
const ret = renderWithoutAct(ui, {
...options,
wrapper: props => {
const ParentWrapper = options?.wrapper ?? React.Fragment
Expand All @@ -263,7 +263,11 @@ export function createRenderStream<
)
},
})
}) as typeof renderWithoutAct
if (stream.renders.length === 0) {
await stream.waitForNextRender()
}
return ret
}) as unknown as RenderWithoutActAsync // TODO

Object.assign<typeof stream, typeof stream>(stream, {
replaceSnapshot,
Expand Down
23 changes: 23 additions & 0 deletions src/renderStream/renderWithoutAct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,29 @@ function renderRoot(
} as RenderResult<Queries, any, any> // TODO clean up more
}

export type RenderWithoutActAsync = {
<
Q extends Queries = SyncQueries,
Container extends ReactDOMClient.Container = HTMLElement,
BaseElement extends ReactDOMClient.Container = Container,
>(
this: any,
ui: React.ReactNode,
options: //Omit<
RenderOptions<Q, Container, BaseElement>,
//'hydrate' | 'legacyRoot' >,
): Promise<RenderResult<Q, Container, BaseElement>>
(
this: any,
ui: React.ReactNode,
options?:
| Omit<RenderOptions, 'hydrate' | 'legacyRoot' | 'queries'>
| undefined,
): Promise<
RenderResult<Queries, ReactDOMClient.Container, ReactDOMClient.Container>
>
}

export function renderWithoutAct<
Q extends Queries = SyncQueries,
Container extends ReactDOMClient.Container = HTMLElement,
Expand Down
35 changes: 35 additions & 0 deletions src/useWithoutAct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {getConfig} from '@testing-library/dom'
import {UserEvent} from '@testing-library/user-event'

type AsyncUserEvent = {
[K in keyof UserEvent as UserEvent[K] extends (...args: any[]) => Promise<any>
? K
: never]: UserEvent[K]
}

export function userEventWithoutAct(
userEvent: UserEvent | typeof import('@testing-library/user-event').userEvent,
): AsyncUserEvent {
return Object.fromEntries(
Object.entries(userEvent).map(([key, value]) => {
if (typeof value === 'function') {
return [
key,
async function wrapped(this: any, ...args: any[]) {
const config = getConfig()
// eslint-disable-next-line @typescript-eslint/unbound-method
const orig = config.eventWrapper
try {
config.eventWrapper = cb => cb()
// eslint-disable-next-line @typescript-eslint/return-await
return await (value as Function).apply(this, args)
} finally {
config.eventWrapper = orig
}
},
]
}
return [key, value]
}),
) as AsyncUserEvent
}

0 comments on commit 5ff5420

Please sign in to comment.