From ddf9df9619cac1c628ce035fc31efb8e40d110b9 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 26 Nov 2024 17:59:52 +0100 Subject: [PATCH 01/37] add `renderWithoutAct` helper --- src/pure.ts | 2 + src/renderStream/createRenderStream.tsx | 9 +- src/renderStream/renderWithoutAct.tsx | 152 ++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/renderStream/renderWithoutAct.tsx diff --git a/src/pure.ts b/src/pure.ts index 9b2c0fca4..5ec488adc 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -18,3 +18,5 @@ export {renderHookToSnapshotStream} from './renderHookToSnapshotStream.js' export type {SnapshotStream} from './renderHookToSnapshotStream.js' export type {Assertable} from './assertable.js' + +export {renderWithoutAct} from './renderStream/renderWithoutAct.js' diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 2c87ffe46..2e87a5c74 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -1,12 +1,13 @@ import * as React from 'rehackt' -import {render as baseRender, RenderOptions} from '@testing-library/react' +import {RenderOptions} from '@testing-library/react' import {Assertable, markAssertable} from '../assertable.js' import {RenderInstance, type Render, type BaseRender} from './Render.js' 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' export type ValidSnapshot = // eslint-disable-next-line @typescript-eslint/no-invalid-void-type @@ -81,7 +82,7 @@ export interface RenderStreamWithRenderFn< Snapshot extends ValidSnapshot, Q extends Queries = SyncQueries, > extends RenderStream { - render: typeof baseRender + render: typeof renderWithoutAct } export type RenderStreamOptions< @@ -251,7 +252,7 @@ export function createRenderStream< ui: React.ReactNode, options?: RenderOptions, ) => { - return baseRender(ui, { + return renderWithoutAct(ui, { ...options, wrapper: props => { const ParentWrapper = options?.wrapper ?? React.Fragment @@ -262,7 +263,7 @@ export function createRenderStream< ) }, }) - }) as typeof baseRender + }) as typeof renderWithoutAct Object.assign(stream, { replaceSnapshot, diff --git a/src/renderStream/renderWithoutAct.tsx b/src/renderStream/renderWithoutAct.tsx new file mode 100644 index 000000000..715bb99f6 --- /dev/null +++ b/src/renderStream/renderWithoutAct.tsx @@ -0,0 +1,152 @@ +import * as ReactDOMClient from 'react-dom/client' +import { + getQueriesForElement, + prettyDOM, + Queries, + type RenderOptions, + type RenderResult, +} from '@testing-library/react' +import React from 'react' +import {SyncQueries} from './syncQueries.js' + +// Ideally we'd just use a WeakMap where containers are keys and roots are values. +// We use two variables so that we can bail out in constant time when we render with a new container (most common use case) + +const mountedContainers: Set = new Set() +const mountedRootEntries: Array<{ + container: import('react-dom').Container + root: ReturnType +}> = [] + +function renderRoot( + ui: React.ReactNode, + { + baseElement, + container, + queries, + wrapper: WrapperComponent, + root, + }: Pick, 'queries' | 'wrapper'> & { + baseElement: ReactDOMClient.Container + container: ReactDOMClient.Container + root: ReturnType + }, +): RenderResult { + root.render( + WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, + ) + + return { + container, + baseElement, + debug: (el = baseElement, maxLength, options) => + Array.isArray(el) + ? // eslint-disable-next-line no-console + el.forEach(e => + console.log(prettyDOM(e as Element, maxLength, options)), + ) + : // eslint-disable-next-line no-console, + console.log(prettyDOM(el as Element, maxLength, options)), + unmount: () => { + root.unmount() + }, + rerender: rerenderUi => { + renderRoot(rerenderUi, { + container, + baseElement, + root, + wrapper: WrapperComponent, + }) + // Intentionally do not return anything to avoid unnecessarily complicating the API. + // folks can use all the same utilities we return in the first place that are bound to the container + }, + asFragment: () => { + /* istanbul ignore else (old jsdom limitation) */ + if (typeof document.createRange === 'function') { + return document + .createRange() + .createContextualFragment((container as HTMLElement).innerHTML) + } else { + const template = document.createElement('template') + template.innerHTML = (container as HTMLElement).innerHTML + return template.content + } + }, + ...getQueriesForElement(baseElement as HTMLElement, queries), + } as RenderResult // TODO clean up more +} + +export function renderWithoutAct< + Q extends Queries = SyncQueries, + Container extends ReactDOMClient.Container = HTMLElement, + BaseElement extends ReactDOMClient.Container = Container, +>( + ui: React.ReactNode, + options: //Omit< + RenderOptions, + //'hydrate' | 'legacyRoot' >, +): RenderResult +export function renderWithoutAct( + ui: React.ReactNode, + options?: + | Omit + | undefined, +): RenderResult + +export function renderWithoutAct( + ui: React.ReactNode, + { + container, + baseElement = container, + queries, + wrapper, + }: Omit< + RenderOptions, + 'hydrate' | 'legacyRoot' + > = {}, +): 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 + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + let root: ReturnType + // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first. + if (!mountedContainers.has(container)) { + root = createConcurrentRoot(container) + + mountedRootEntries.push({container, root}) + // we'll add it to the mounted containers regardless of whether it's actually + // added to document.body so the cleanup method works regardless of whether + // they're passing us a custom container or not. + mountedContainers.add(container) + } else { + mountedRootEntries.forEach(rootEntry => { + // Else is unreachable since `mountedContainers` has the `container`. + // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + /* istanbul ignore else */ + if (rootEntry.container === container) { + root = rootEntry.root + } + }) + } + + return renderRoot(ui, {baseElement, container, queries, wrapper, root: root!}) +} + +function createConcurrentRoot(container: ReactDOMClient.Container) { + const root = ReactDOMClient.createRoot(container) + + return { + render(element: React.ReactNode) { + root.render(element) + }, + unmount() { + root.unmount() + }, + } +} From e8616b307e4afb3f71c5f0ec54747796520f6c7a Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 26 Nov 2024 18:51:50 +0100 Subject: [PATCH 02/37] painful progress --- .../renderHookToSnapshotStream.test.tsx | 11 +++-- src/__tests__/renderToRenderStream.test.tsx | 10 ++-- src/index.ts | 6 +++ src/pure.ts | 2 +- .../__tests__/createRenderStream.test.tsx | 49 ++++++++++++------- src/renderStream/renderWithoutAct.tsx | 14 ++++++ 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/__tests__/renderHookToSnapshotStream.test.tsx b/src/__tests__/renderHookToSnapshotStream.test.tsx index d09a84aee..9c1c56c8b 100644 --- a/src/__tests__/renderHookToSnapshotStream.test.tsx +++ b/src/__tests__/renderHookToSnapshotStream.test.tsx @@ -6,6 +6,9 @@ import {renderHookToSnapshotStream} from '@testing-library/react-render-stream' import * as React from 'react' import {withDisabledActWarnings} from '../__testHelpers__/withDisabledActWarnings.js' +// @ts-expect-error this is not defined anywhere +globalThis.IS_REACT_ACT_ENVIRONMENT = false + const testEvents = new EventEmitter<{ rerenderWithValue: [unknown] }>() @@ -33,13 +36,13 @@ test('basic functionality', async () => { const {takeSnapshot} = 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') @@ -62,12 +65,12 @@ test.each<[type: string, initialValue: unknown, ...nextValues: unknown[]]>([ const {takeSnapshot} = 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) } diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index 195ed6ad4..32d270c33 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -3,6 +3,10 @@ import {describe, test, expect} from '@jest/globals' import {renderToRenderStream} from '@testing-library/react-render-stream' import {userEvent} from '@testing-library/user-event' import * as React from 'react' + +// @ts-expect-error this is not defined anywhere +globalThis.IS_REACT_ACT_ENVIRONMENT = false + function CounterForm({ value, onIncrement, @@ -39,14 +43,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('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('Value') diff --git a/src/index.ts b/src/index.ts index 9f3c829af..f172c2f3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ import '@testing-library/react-render-stream/expect' +import {cleanup} from '@testing-library/react-render-stream/pure' export * from '@testing-library/react-render-stream/pure' + +const global = globalThis as {afterEach?: (fn: () => void) => void} +if (global.afterEach) { + global.afterEach(cleanup) +} diff --git a/src/pure.ts b/src/pure.ts index 5ec488adc..185e80110 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -19,4 +19,4 @@ export type {SnapshotStream} from './renderHookToSnapshotStream.js' export type {Assertable} from './assertable.js' -export {renderWithoutAct} from './renderStream/renderWithoutAct.js' +export {renderWithoutAct, cleanup} from './renderStream/renderWithoutAct.js' diff --git a/src/renderStream/__tests__/createRenderStream.test.tsx b/src/renderStream/__tests__/createRenderStream.test.tsx index c7f0908cf..443e0feac 100644 --- a/src/renderStream/__tests__/createRenderStream.test.tsx +++ b/src/renderStream/__tests__/createRenderStream.test.tsx @@ -1,11 +1,22 @@ /* 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 {userEvent} 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)) +} + function CounterForm({ value, onIncrement, @@ -39,14 +50,14 @@ describe('snapshotDOM', () => { snapshotDOM: true, }) const utils = render() - const incrementButton = utils.getByText('Increment') - await userEvent.click(incrementButton) - await userEvent.click(incrementButton) { const {withinDOM} = await takeRender() const input = withinDOM().getByLabelText('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 @@ -76,7 +87,7 @@ describe('snapshotDOM', () => { const {withinDOM} = await takeRender() const snapshotIncrementButton = withinDOM().getByText('Increment') try { - await userEvent.click(snapshotIncrementButton) + await click(snapshotIncrementButton) } catch (error) { expect(error).toMatchInlineSnapshot(` [Error: Uncaught [Error: @@ -130,13 +141,13 @@ describe('replaceSnapshot', () => { value: number }>() const utils = render() - 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}) @@ -160,13 +171,14 @@ describe('replaceSnapshot', () => { initialSnapshot: {unrelatedValue: 'unrelated', value: -1}, }) const utils = render() - 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}) @@ -203,6 +215,8 @@ describe('replaceSnapshot', () => { , ) + await new Promise(r => setTimeout(r, 10)) + spy.mockRestore() expect(caughtError!).toMatchInlineSnapshot( @@ -231,10 +245,11 @@ describe('onRender', () => { }, }) const utils = render() - 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() }) @@ -254,10 +269,10 @@ describe('onRender', () => { }) const utils = render() - 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(` diff --git a/src/renderStream/renderWithoutAct.tsx b/src/renderStream/renderWithoutAct.tsx index 715bb99f6..2033906ab 100644 --- a/src/renderStream/renderWithoutAct.tsx +++ b/src/renderStream/renderWithoutAct.tsx @@ -32,6 +32,8 @@ function renderRoot( root: ReturnType }, ): RenderResult { + // @ts-expect-error this is not defined anywhere + globalThis.IS_REACT_ACT_ENVIRONMENT = false root.render( WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, ) @@ -150,3 +152,15 @@ function createConcurrentRoot(container: ReactDOMClient.Container) { }, } } + +export function cleanup() { + mountedRootEntries.forEach(({root, container}) => { + root.unmount() + + if (container.parentNode === document.body) { + document.body.removeChild(container) + } + }) + mountedRootEntries.length = 0 + mountedContainers.clear() +} From 5ff5420511621cffcb3b7fc4b86f0a878e92ef13 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 27 Nov 2024 10:38:02 +0100 Subject: [PATCH 03/37] make render async to wait for first render --- .../renderHookToSnapshotStream.test.tsx | 12 ++-- src/__tests__/renderToRenderStream.test.tsx | 15 ++-- src/pure.ts | 1 + src/renderHookToSnapshotStream.tsx | 6 +- .../__tests__/createRenderStream.test.tsx | 70 ++++++++----------- src/renderStream/createRenderStream.tsx | 14 ++-- src/renderStream/renderWithoutAct.tsx | 23 ++++++ src/useWithoutAct.ts | 35 ++++++++++ 8 files changed, 117 insertions(+), 59 deletions(-) create mode 100644 src/useWithoutAct.ts diff --git a/src/__tests__/renderHookToSnapshotStream.test.tsx b/src/__tests__/renderHookToSnapshotStream.test.tsx index 9c1c56c8b..85d2bcec4 100644 --- a/src/__tests__/renderHookToSnapshotStream.test.tsx +++ b/src/__tests__/renderHookToSnapshotStream.test.tsx @@ -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') @@ -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) } diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index 32d270c33..5f93680c5 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -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, @@ -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('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('Value') diff --git a/src/pure.ts b/src/pure.ts index 185e80110..f99e9c590 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -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' diff --git a/src/renderHookToSnapshotStream.tsx b/src/renderHookToSnapshotStream.tsx index 3d8f6edb7..2b9599fb8 100644 --- a/src/renderHookToSnapshotStream.tsx +++ b/src/renderHookToSnapshotStream.tsx @@ -45,10 +45,10 @@ export interface SnapshotStream extends Assertable { unmount: () => void } -export function renderHookToSnapshotStream( +export async function renderHookToSnapshotStream( renderCallback: (props: Props) => ReturnValue, {initialProps, ...renderOptions}: RenderHookOptions = {}, -): SnapshotStream { +): Promise> { const {render, ...stream} = createRenderStream<{value: ReturnValue}, never>() const HookComponent: React.FC<{arg: Props}> = props => { @@ -56,7 +56,7 @@ export function renderHookToSnapshotStream( return null } - const {rerender: baseRerender, unmount} = render( + const {rerender: baseRerender, unmount} = await render( , renderOptions, ) diff --git a/src/renderStream/__tests__/createRenderStream.test.tsx b/src/renderStream/__tests__/createRenderStream.test.tsx index 443e0feac..b515c7d64 100644 --- a/src/renderStream/__tests__/createRenderStream.test.tsx +++ b/src/renderStream/__tests__/createRenderStream.test.tsx @@ -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, @@ -49,15 +43,15 @@ describe('snapshotDOM', () => { const {takeRender, render} = createRenderStream({ snapshotDOM: true, }) - const utils = render() + const utils = await render() + const incrementButton = utils.getByText('Increment') + await userEvent.click(incrementButton) + await userEvent.click(incrementButton) { const {withinDOM} = await takeRender() const input = withinDOM().getByLabelText('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 @@ -82,12 +76,12 @@ describe('snapshotDOM', () => { const {takeRender, render} = createRenderStream({ snapshotDOM: true, }) - render() + await render() { 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: @@ -114,7 +108,7 @@ describe('snapshotDOM', () => { snapshotDOM: true, queries, }) - render() + await render() const {withinDOM} = await takeRender() expect(withinDOM().foo()).toBe(null) @@ -140,14 +134,14 @@ describe('replaceSnapshot', () => { const {takeRender, replaceSnapshot, render} = createRenderStream<{ value: number }>() - const utils = render() + const utils = await render() + 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}) @@ -170,15 +164,14 @@ describe('replaceSnapshot', () => { const {takeRender, replaceSnapshot, render} = createRenderStream({ initialSnapshot: {unrelatedValue: 'unrelated', value: -1}, }) - const utils = render() + const utils = await render() + 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}) @@ -204,7 +197,7 @@ describe('replaceSnapshot', () => { const spy = jest.spyOn(console, 'error') spy.mockImplementation(() => {}) - render( + await render( { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -215,8 +208,6 @@ describe('replaceSnapshot', () => { , ) - await new Promise(r => setTimeout(r, 10)) - spy.mockRestore() expect(caughtError!).toMatchInlineSnapshot( @@ -244,12 +235,11 @@ describe('onRender', () => { expect(info.count).toBe(info.snapshot.value + 1) }, }) - const utils = render() + const utils = await render() + 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() }) @@ -268,11 +258,11 @@ describe('onRender', () => { }, }) - const utils = render() + const utils = await render() + 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(` diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 2e87a5c74..68f8ea9dc 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -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 @@ -82,7 +82,7 @@ export interface RenderStreamWithRenderFn< Snapshot extends ValidSnapshot, Q extends Queries = SyncQueries, > extends RenderStream { - render: typeof renderWithoutAct + render: RenderWithoutActAsync } export type RenderStreamOptions< @@ -248,11 +248,11 @@ export function createRenderStream< ) } - const render = (( + const render: RenderWithoutActAsync = (async ( ui: React.ReactNode, options?: RenderOptions, ) => { - return renderWithoutAct(ui, { + const ret = renderWithoutAct(ui, { ...options, wrapper: props => { const ParentWrapper = options?.wrapper ?? React.Fragment @@ -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(stream, { replaceSnapshot, diff --git a/src/renderStream/renderWithoutAct.tsx b/src/renderStream/renderWithoutAct.tsx index 2033906ab..d098eedcd 100644 --- a/src/renderStream/renderWithoutAct.tsx +++ b/src/renderStream/renderWithoutAct.tsx @@ -78,6 +78,29 @@ function renderRoot( } as RenderResult // 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, + //'hydrate' | 'legacyRoot' >, + ): Promise> + ( + this: any, + ui: React.ReactNode, + options?: + | Omit + | undefined, + ): Promise< + RenderResult + > +} + export function renderWithoutAct< Q extends Queries = SyncQueries, Container extends ReactDOMClient.Container = HTMLElement, diff --git a/src/useWithoutAct.ts b/src/useWithoutAct.ts new file mode 100644 index 000000000..f22c07ede --- /dev/null +++ b/src/useWithoutAct.ts @@ -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 + ? 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 +} From c124751a58034db776ae54de345418d2ab0da546 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 27 Nov 2024 10:59:12 +0100 Subject: [PATCH 04/37] all tests passing --- .../__tests__/renderStreamMatchers.test.tsx | 24 +++++++++---------- .../__tests__/useTrackRenders.test.tsx | 10 ++++---- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/expect/__tests__/renderStreamMatchers.test.tsx b/src/expect/__tests__/renderStreamMatchers.test.tsx index 6466df652..614150fa4 100644 --- a/src/expect/__tests__/renderStreamMatchers.test.tsx +++ b/src/expect/__tests__/renderStreamMatchers.test.tsx @@ -34,7 +34,7 @@ describe('toRerender', () => { test('basic functionality', async () => { const {takeRender, render} = createRenderStream({}) - render() + await render() await expect(takeRender).toRerender() await takeRender() @@ -48,7 +48,7 @@ describe('toRerender', () => { test('works with renderStream object', async () => { const renderStream = createRenderStream({}) - renderStream.render() + await renderStream.render() await expect(renderStream).toRerender() await renderStream.takeRender() @@ -60,7 +60,7 @@ describe('toRerender', () => { }) test('works with takeSnapshot function', async () => { - const {takeSnapshot} = renderHookToSnapshotStream(() => useRerender()) + const {takeSnapshot} = await renderHookToSnapshotStream(() => useRerender()) await expect(takeSnapshot).toRerender() await takeSnapshot() @@ -73,7 +73,7 @@ describe('toRerender', () => { }) test('works with snapshotStream', async () => { - const snapshotStream = renderHookToSnapshotStream(() => useRerender()) + const snapshotStream = await renderHookToSnapshotStream(() => useRerender()) await expect(snapshotStream).toRerender() await snapshotStream.takeSnapshot() @@ -88,7 +88,7 @@ describe('toRerender', () => { test("errors when it rerenders, but shouldn't", async () => { const {takeRender, render} = createRenderStream({}) - render() + await render() await expect(takeRender).toRerender() await takeRender() @@ -106,7 +106,7 @@ Expected component to not rerender, but it did. test("errors when it should rerender, but doesn't", async () => { const {takeRender, render} = createRenderStream({}) - render() + await render() await expect(takeRender).toRerender() await takeRender() @@ -123,7 +123,7 @@ describe('toRenderExactlyTimes', () => { test('basic functionality', async () => { const {takeRender, render} = createRenderStream({}) - render() + await render() testEvents.emit('rerender') await expect(takeRender).toRenderExactlyTimes(2) @@ -132,21 +132,21 @@ describe('toRenderExactlyTimes', () => { test('works with renderStream object', async () => { const renderStream = createRenderStream({}) - renderStream.render() + await renderStream.render() testEvents.emit('rerender') await expect(renderStream).toRenderExactlyTimes(2) }) test('works with takeSnapshot function', async () => { - const {takeSnapshot} = renderHookToSnapshotStream(() => useRerender()) + const {takeSnapshot} = await renderHookToSnapshotStream(() => useRerender()) testEvents.emit('rerender') await expect(takeSnapshot).toRenderExactlyTimes(2) }) test('works with snapshotStream', async () => { - const snapshotStream = renderHookToSnapshotStream(() => useRerender()) + const snapshotStream = await renderHookToSnapshotStream(() => useRerender()) testEvents.emit('rerender') await expect(snapshotStream).toRenderExactlyTimes(2) @@ -155,7 +155,7 @@ describe('toRenderExactlyTimes', () => { test('errors when the count of rerenders is wrong', async () => { const {takeRender, render} = createRenderStream({}) - render() + await render() testEvents.emit('rerender') const error = await getExpectErrorMessage( @@ -172,7 +172,7 @@ It rendered 2 times. test('errors when the count of rerenders is right (inverted)', async () => { const {takeRender, render} = createRenderStream({}) - render() + await render() testEvents.emit('rerender') const error = await getExpectErrorMessage( diff --git a/src/renderStream/__tests__/useTrackRenders.test.tsx b/src/renderStream/__tests__/useTrackRenders.test.tsx index 97b8a3488..86d65c334 100644 --- a/src/renderStream/__tests__/useTrackRenders.test.tsx +++ b/src/renderStream/__tests__/useTrackRenders.test.tsx @@ -61,7 +61,7 @@ describe('non-suspense use cases', () => { test('basic functionality', async () => { const {takeRender, render} = createRenderStream() - render() + await render() asyncAction.resolve('data') { const {renderedComponents} = await takeRender() @@ -75,7 +75,7 @@ describe('non-suspense use cases', () => { test('error path', async () => { const {takeRender, render} = createRenderStream() - render() + await render() asyncAction.reject(new Error('error')) { const {renderedComponents} = await takeRender() @@ -119,7 +119,7 @@ describe('suspense use cases', () => { test('basic functionality', async () => { const {takeRender, render} = createRenderStream() - render() + await render() asyncAction.resolve('data') { const {renderedComponents} = await takeRender() @@ -133,7 +133,7 @@ describe('suspense use cases', () => { test('ErrorBoundary', async () => { const {takeRender, render} = createRenderStream() - render() + await render() const spy = jest.spyOn(console, 'error') spy.mockImplementation(() => {}) @@ -156,7 +156,7 @@ test('specifying the `name` option', async () => { return <>{children} } const {takeRender, render} = createRenderStream() - render( + await render( <> From 20ef3ebe9e68bc7413f9cca0752c0745cc5d926a Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 27 Nov 2024 11:39:57 +0100 Subject: [PATCH 05/37] more tweaking --- .../renderHookToSnapshotStream.test.tsx | 6 +---- src/__tests__/renderToRenderStream.test.tsx | 3 --- src/disableActEnvironment.ts | 22 +++++++++++++++++++ .../__tests__/renderStreamMatchers.test.tsx | 3 +-- src/pure.ts | 4 +++- src/renderHookToSnapshotStream.tsx | 2 +- src/renderStream/createRenderStream.tsx | 8 +++---- src/renderStream/disableActWarnings.ts | 16 -------------- src/{renderStream => }/renderWithoutAct.tsx | 15 +++++++------ ...nings.ts => withDisabledActEnvironment.ts} | 6 ++--- 10 files changed, 43 insertions(+), 42 deletions(-) create mode 100644 src/disableActEnvironment.ts delete mode 100644 src/renderStream/disableActWarnings.ts rename src/{renderStream => }/renderWithoutAct.tsx (93%) rename src/{__testHelpers__/withDisabledActWarnings.ts => withDisabledActEnvironment.ts} (52%) diff --git a/src/__tests__/renderHookToSnapshotStream.test.tsx b/src/__tests__/renderHookToSnapshotStream.test.tsx index 85d2bcec4..95179a6cc 100644 --- a/src/__tests__/renderHookToSnapshotStream.test.tsx +++ b/src/__tests__/renderHookToSnapshotStream.test.tsx @@ -4,10 +4,6 @@ import {EventEmitter} from 'node:events' import {test, expect} from '@jest/globals' import {renderHookToSnapshotStream} from '@testing-library/react-render-stream' import * as React from 'react' -import {withDisabledActWarnings} from '../__testHelpers__/withDisabledActWarnings.js' - -// @ts-expect-error this is not defined anywhere -globalThis.IS_REACT_ACT_ENVIRONMENT = false const testEvents = new EventEmitter<{ rerenderWithValue: [unknown] @@ -19,7 +15,7 @@ function useRerenderEvents(initialValue: unknown) { onChange => { const cb = (value: unknown) => { lastValueRef.current = value - withDisabledActWarnings(onChange) + onChange() } testEvents.addListener('rerenderWithValue', cb) return () => { diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index 5f93680c5..d5b3ee348 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -7,9 +7,6 @@ import { 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({ diff --git a/src/disableActEnvironment.ts b/src/disableActEnvironment.ts new file mode 100644 index 000000000..a3f2050ed --- /dev/null +++ b/src/disableActEnvironment.ts @@ -0,0 +1,22 @@ +const dispose: typeof Symbol.dispose = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + Symbol.dispose ?? Symbol.for('nodejs.dispose') + +/** + * Temporarily disable act warnings. + * + * https://github.com/reactwg/react-18/discussions/102 + */ +export function disableActEnvironment(): {cleanup: () => void} & Disposable { + const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} + const prevActEnv = anyThis.IS_REACT_ACT_ENVIRONMENT + anyThis.IS_REACT_ACT_ENVIRONMENT = false + + function cleanup() { + anyThis.IS_REACT_ACT_ENVIRONMENT = prevActEnv + } + return { + cleanup, + [dispose]: cleanup, + } +} diff --git a/src/expect/__tests__/renderStreamMatchers.test.tsx b/src/expect/__tests__/renderStreamMatchers.test.tsx index 614150fa4..ea19ced1b 100644 --- a/src/expect/__tests__/renderStreamMatchers.test.tsx +++ b/src/expect/__tests__/renderStreamMatchers.test.tsx @@ -7,7 +7,6 @@ import { } from '@testing-library/react-render-stream' import * as React from 'react' import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js' -import {withDisabledActWarnings} from '../../__testHelpers__/withDisabledActWarnings.js' const testEvents = new EventEmitter<{ rerender: [] @@ -16,7 +15,7 @@ const testEvents = new EventEmitter<{ function useRerender() { const [, rerender] = React.useReducer(c => c + 1, 0) React.useEffect(() => { - const cb = () => void withDisabledActWarnings(rerender) + const cb = () => void rerender() testEvents.addListener('rerender', cb) return () => { diff --git a/src/pure.ts b/src/pure.ts index f99e9c590..5514e7816 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -19,5 +19,7 @@ export type {SnapshotStream} from './renderHookToSnapshotStream.js' export type {Assertable} from './assertable.js' -export {renderWithoutAct, cleanup} from './renderStream/renderWithoutAct.js' +export {renderWithoutAct, cleanup} from './renderWithoutAct.js' export {userEventWithoutAct} from './useWithoutAct.js' +export {disableActEnvironment} from './disableActEnvironment.js' +export {withDisabledActEnvironment} from './withDisabledActEnvironment.js' diff --git a/src/renderHookToSnapshotStream.tsx b/src/renderHookToSnapshotStream.tsx index 2b9599fb8..7d938271c 100644 --- a/src/renderHookToSnapshotStream.tsx +++ b/src/renderHookToSnapshotStream.tsx @@ -1,4 +1,4 @@ -import {RenderHookOptions} from '@testing-library/react' +import {type RenderHookOptions} from '@testing-library/react' import React from 'rehackt' import {createRenderStream} from './renderStream/createRenderStream.js' import {type NextRenderOptions} from './renderStream/createRenderStream.js' diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 68f8ea9dc..c374db05b 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -2,12 +2,12 @@ import * as React from 'rehackt' import {RenderOptions} from '@testing-library/react' import {Assertable, markAssertable} from '../assertable.js' +import {disableActEnvironment} from '../disableActEnvironment.js' +import {renderWithoutAct, RenderWithoutActAsync} from '../renderWithoutAct.js' import {RenderInstance, type Render, type BaseRender} from './Render.js' 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, RenderWithoutActAsync} from './renderWithoutAct.js' export type ValidSnapshot = // eslint-disable-next-line @typescript-eslint/no-invalid-void-type @@ -299,7 +299,7 @@ export function createRenderStream< // In many cases we do not control the resolution of the suspended // promise which results in noisy tests when the profiler due to // repeated act warnings. - const disabledActWarnings = disableActWarnings() + const disabledAct = disableActEnvironment() let error: unknown @@ -317,7 +317,7 @@ export function createRenderStream< if (!(error && error instanceof WaitForRenderTimeoutError)) { iteratorPosition++ } - disabledActWarnings.cleanup() + disabledAct.cleanup() } }, stream), getCurrentRender() { diff --git a/src/renderStream/disableActWarnings.ts b/src/renderStream/disableActWarnings.ts deleted file mode 100644 index 99b8cd65e..000000000 --- a/src/renderStream/disableActWarnings.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Temporarily disable act warnings. - * - * https://github.com/reactwg/react-18/discussions/102 - */ -export function disableActWarnings() { - const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} - const prevActEnv = anyThis.IS_REACT_ACT_ENVIRONMENT - anyThis.IS_REACT_ACT_ENVIRONMENT = false - - return { - cleanup: () => { - anyThis.IS_REACT_ACT_ENVIRONMENT = prevActEnv - }, - } -} diff --git a/src/renderStream/renderWithoutAct.tsx b/src/renderWithoutAct.tsx similarity index 93% rename from src/renderStream/renderWithoutAct.tsx rename to src/renderWithoutAct.tsx index d098eedcd..76b50538f 100644 --- a/src/renderStream/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -5,9 +5,10 @@ import { Queries, type RenderOptions, type RenderResult, -} from '@testing-library/react' +} from '@testing-library/react/pure.js' import React from 'react' -import {SyncQueries} from './syncQueries.js' +import {SyncQueries} from './renderStream/syncQueries.js' +import {withDisabledActEnvironment} from './withDisabledActEnvironment.js' // Ideally we'd just use a WeakMap where containers are keys and roots are values. // We use two variables so that we can bail out in constant time when we render with a new container (most common use case) @@ -32,8 +33,6 @@ function renderRoot( root: ReturnType }, ): RenderResult { - // @ts-expect-error this is not defined anywhere - globalThis.IS_REACT_ACT_ENVIRONMENT = false root.render( WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, ) @@ -164,14 +163,16 @@ export function renderWithoutAct( } function createConcurrentRoot(container: ReactDOMClient.Container) { - const root = ReactDOMClient.createRoot(container) + const root = withDisabledActEnvironment(() => + ReactDOMClient.createRoot(container), + ) return { render(element: React.ReactNode) { - root.render(element) + withDisabledActEnvironment(() => root.render(element)) }, unmount() { - root.unmount() + withDisabledActEnvironment(() => root.unmount()) }, } } diff --git a/src/__testHelpers__/withDisabledActWarnings.ts b/src/withDisabledActEnvironment.ts similarity index 52% rename from src/__testHelpers__/withDisabledActWarnings.ts rename to src/withDisabledActEnvironment.ts index ee76a9411..d8c9def59 100644 --- a/src/__testHelpers__/withDisabledActWarnings.ts +++ b/src/withDisabledActEnvironment.ts @@ -1,7 +1,7 @@ -import {disableActWarnings} from '../renderStream/disableActWarnings.js' +import {disableActEnvironment} from './disableActEnvironment.js' -export function withDisabledActWarnings(cb: () => T): T { - const disabledActWarnings = disableActWarnings() +export function withDisabledActEnvironment(cb: () => T): T { + const disabledActWarnings = disableActEnvironment() let result: T try { result = cb() From b086f980d25e3d92f2a21d2773b98eacd9a6223b Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 27 Nov 2024 12:21:07 +0100 Subject: [PATCH 06/37] fix type --- src/renderWithoutAct.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index 76b50538f..c8826447f 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -96,7 +96,7 @@ export type RenderWithoutActAsync = { | Omit | undefined, ): Promise< - RenderResult + RenderResult > } From 6fb6fb16b5c3139e9a7f71738b4494883d8c3fc0 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 27 Nov 2024 14:03:40 +0100 Subject: [PATCH 07/37] also wrap asyncWrapper --- src/useWithoutAct.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/useWithoutAct.ts b/src/useWithoutAct.ts index f22c07ede..5f290f1d4 100644 --- a/src/useWithoutAct.ts +++ b/src/useWithoutAct.ts @@ -18,13 +18,16 @@ export function userEventWithoutAct( async function wrapped(this: any, ...args: any[]) { const config = getConfig() // eslint-disable-next-line @typescript-eslint/unbound-method - const orig = config.eventWrapper + const eventWrapper = config.eventWrapper + // eslint-disable-next-line @typescript-eslint/unbound-method + const asyncWrapper = config.asyncWrapper 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 + config.asyncWrapper = asyncWrapper + config.eventWrapper = eventWrapper } }, ] From 8474b54ce047adbf7efb2997f83e66c0df22b76b Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 27 Nov 2024 14:33:02 +0100 Subject: [PATCH 08/37] export `RenderWithoutActAsync` type --- src/pure.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pure.ts b/src/pure.ts index 5514e7816..5e2ce46b4 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -19,7 +19,11 @@ export type {SnapshotStream} from './renderHookToSnapshotStream.js' export type {Assertable} from './assertable.js' -export {renderWithoutAct, cleanup} from './renderWithoutAct.js' +export { + renderWithoutAct, + cleanup, + type RenderWithoutActAsync, +} from './renderWithoutAct.js' export {userEventWithoutAct} from './useWithoutAct.js' export {disableActEnvironment} from './disableActEnvironment.js' export {withDisabledActEnvironment} from './withDisabledActEnvironment.js' From d926b40d4ebbb799aca2c696a7d8e115ab1a626b Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 28 Nov 2024 17:23:41 +0100 Subject: [PATCH 09/37] build legacy root in React 16/17 --- src/renderWithoutAct.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index c8826447f..e707e4052 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -1,4 +1,5 @@ import * as ReactDOMClient from 'react-dom/client' +import * as ReactDOM from 'react-dom' import { getQueriesForElement, prettyDOM, @@ -96,7 +97,11 @@ export type RenderWithoutActAsync = { | Omit | undefined, ): Promise< - RenderResult + RenderResult< + SyncQueries, + ReactDOMClient.Container, + ReactDOMClient.Container + > > } @@ -141,8 +146,11 @@ export function renderWithoutAct( let root: ReturnType // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first. if (!mountedContainers.has(container)) { - root = createConcurrentRoot(container) - + root = ( + ReactDOM.version.startsWith('16') || ReactDOM.version.startsWith('17') + ? createLegacyRoot + : createConcurrentRoot + )(container) mountedRootEntries.push({container, root}) // we'll add it to the mounted containers regardless of whether it's actually // added to document.body so the cleanup method works regardless of whether @@ -162,6 +170,17 @@ export function renderWithoutAct( return renderRoot(ui, {baseElement, container, queries, wrapper, root: root!}) } +function createLegacyRoot(container: ReactDOMClient.Container) { + return { + render(element: React.ReactNode) { + ReactDOM.render(element as unknown as React.ReactElement, container) + }, + unmount() { + ReactDOM.unmountComponentAtNode(container) + }, + } +} + function createConcurrentRoot(container: ReactDOMClient.Container) { const root = withDisabledActEnvironment(() => ReactDOMClient.createRoot(container), From 1ad050fa4fc3a0a54cffc62c4492839a7766eedd Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 28 Nov 2024 19:34:24 +0100 Subject: [PATCH 10/37] disable act with confidence --- src/renderWithoutAct.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index e707e4052..87feb9abb 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -173,10 +173,14 @@ export function renderWithoutAct( function createLegacyRoot(container: ReactDOMClient.Container) { return { render(element: React.ReactNode) { - ReactDOM.render(element as unknown as React.ReactElement, container) + withDisabledActEnvironment(() => + ReactDOM.render(element as unknown as React.ReactElement, container), + ) }, unmount() { - ReactDOM.unmountComponentAtNode(container) + withDisabledActEnvironment(() => + ReactDOM.unmountComponentAtNode(container), + ) }, } } From 9ef55bb5828ed670cdf0b939bb8903a0ea69d058 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 12:26:21 +0100 Subject: [PATCH 11/37] don't auto-disable act, throw error instead --- src/disableActEnvironment.ts | 29 ++++++++++++++-- src/renderStream/createRenderStream.tsx | 6 ---- src/renderWithoutAct.tsx | 44 +++++++++++++++---------- src/withDisabledActEnvironment.ts | 10 +++--- 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/disableActEnvironment.ts b/src/disableActEnvironment.ts index a3f2050ed..392ef66ac 100644 --- a/src/disableActEnvironment.ts +++ b/src/disableActEnvironment.ts @@ -3,9 +3,34 @@ const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol.for('nodejs.dispose') /** - * Temporarily disable act warnings. + * Helper to temporarily disable a React 18+ act environment. * - * https://github.com/reactwg/react-18/discussions/102 + * This returns a disposable and can be used in combination with `using` to + * automatically restore the state from before this method call after your test. + * + * @example + * ```ts + * test("my test", () => { + * using _disabledAct = disableActEnvironment(); + * + * // your test code here + * + * // as soon as this scope is left, the environment will be cleaned up + * }) + * ``` + * + * If you can not use the explicit resouce management keyword `using`, + * you can also manually call `cleanup`: + * + * @example + * ```ts + * test("my test", () => { + * const { cleanup } = disableActEnvironment(); + * + * // your test code here + * + * cleanup(); + * }) */ export function disableActEnvironment(): {cleanup: () => void} & Disposable { const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index c374db05b..48e080624 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -296,11 +296,6 @@ export function createRenderStream< takeRender: markAssertable(async function takeRender( options: NextRenderOptions = {}, ) { - // In many cases we do not control the resolution of the suspended - // promise which results in noisy tests when the profiler due to - // repeated act warnings. - const disabledAct = disableActEnvironment() - let error: unknown try { @@ -317,7 +312,6 @@ export function createRenderStream< if (!(error && error instanceof WaitForRenderTimeoutError)) { iteratorPosition++ } - disabledAct.cleanup() } }, stream), getCurrentRender() { diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index 87feb9abb..aa06f17f5 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -173,41 +173,49 @@ export function renderWithoutAct( function createLegacyRoot(container: ReactDOMClient.Container) { return { render(element: React.ReactNode) { - withDisabledActEnvironment(() => - ReactDOM.render(element as unknown as React.ReactElement, container), - ) + ReactDOM.render(element as unknown as React.ReactElement, container) }, unmount() { - withDisabledActEnvironment(() => - ReactDOM.unmountComponentAtNode(container), - ) + ReactDOM.unmountComponentAtNode(container) }, } } function createConcurrentRoot(container: ReactDOMClient.Container) { - const root = withDisabledActEnvironment(() => - ReactDOMClient.createRoot(container), - ) + const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} + if (anyThis.IS_REACT_ACT_ENVIRONMENT) { + throw new Error(`Tried to create a React root for a render stream inside a React act environment. +This is not supported. Please use \`disableActEnvironment\` to disable the act environment for this test.`) + } + const root = ReactDOMClient.createRoot(container) return { render(element: React.ReactNode) { - withDisabledActEnvironment(() => root.render(element)) + if (anyThis.IS_REACT_ACT_ENVIRONMENT) { + throw new Error(`Tried to render a render stream inside a React act environment. + This is not supported. Please use \`disableActEnvironment\` to disable the act environment for this test.`) + } + root.render(element) }, unmount() { - withDisabledActEnvironment(() => root.unmount()) + root.unmount() }, } } export function cleanup() { - mountedRootEntries.forEach(({root, container}) => { - root.unmount() + // there is a good chance this happens outside of a test, where the user + // has no control over enabling or disabling the React Act environment, + // so we do it for them here. + withDisabledActEnvironment(() => { + mountedRootEntries.forEach(({root, container}) => { + root.unmount() - if (container.parentNode === document.body) { - document.body.removeChild(container) - } + if (container.parentNode === document.body) { + document.body.removeChild(container) + } + }) + mountedRootEntries.length = 0 + mountedContainers.clear() }) - mountedRootEntries.length = 0 - mountedContainers.clear() } diff --git a/src/withDisabledActEnvironment.ts b/src/withDisabledActEnvironment.ts index d8c9def59..e6b96098c 100644 --- a/src/withDisabledActEnvironment.ts +++ b/src/withDisabledActEnvironment.ts @@ -2,13 +2,13 @@ import {disableActEnvironment} from './disableActEnvironment.js' export function withDisabledActEnvironment(cb: () => T): T { const disabledActWarnings = disableActEnvironment() - let result: T + let result: T | undefined try { result = cb() - return result instanceof Promise - ? (result.finally(disabledActWarnings.cleanup) as T) - : result + return result } finally { - disabledActWarnings.cleanup() + if (result != null && result instanceof Promise) { + void result.finally(disabledActWarnings.cleanup) + } else disabledActWarnings.cleanup() } } From 2c5b29e25161fadd360f7cb704972a12c635f9d4 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 12:34:45 +0100 Subject: [PATCH 12/37] adjust imports --- src/renderHookToSnapshotStream.tsx | 2 +- src/renderStream/createRenderStream.tsx | 3 +-- src/renderToRenderStream.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderHookToSnapshotStream.tsx b/src/renderHookToSnapshotStream.tsx index 7d938271c..258ad470b 100644 --- a/src/renderHookToSnapshotStream.tsx +++ b/src/renderHookToSnapshotStream.tsx @@ -1,4 +1,4 @@ -import {type RenderHookOptions} from '@testing-library/react' +import {type RenderHookOptions} from '@testing-library/react/pure.js' import React from 'rehackt' import {createRenderStream} from './renderStream/createRenderStream.js' import {type NextRenderOptions} from './renderStream/createRenderStream.js' diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 48e080624..50d9b7ed1 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -1,8 +1,7 @@ import * as React from 'rehackt' -import {RenderOptions} from '@testing-library/react' +import {type RenderOptions} from '@testing-library/react/pure.js' import {Assertable, markAssertable} from '../assertable.js' -import {disableActEnvironment} from '../disableActEnvironment.js' import {renderWithoutAct, RenderWithoutActAsync} from '../renderWithoutAct.js' import {RenderInstance, type Render, type BaseRender} from './Render.js' import {type RenderStreamContextValue} from './context.js' diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 9692918f3..493de118d 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -2,7 +2,7 @@ import { Queries, type RenderOptions as BaseOptions, type RenderResult as BaseResult, -} from '@testing-library/react' +} from '@testing-library/react/pure.js' import { createRenderStream, type RenderStreamOptions, From 69a4c4b72d6596f323ca0065294c85bea41df69b Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 12:48:47 +0100 Subject: [PATCH 13/37] wait a bunch longer so react doesn't batch --- src/__tests__/renderHookToSnapshotStream.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/renderHookToSnapshotStream.test.tsx b/src/__tests__/renderHookToSnapshotStream.test.tsx index 95179a6cc..108da49e8 100644 --- a/src/__tests__/renderHookToSnapshotStream.test.tsx +++ b/src/__tests__/renderHookToSnapshotStream.test.tsx @@ -1,6 +1,7 @@ /* eslint-disable no-await-in-loop */ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ import {EventEmitter} from 'node:events' +import {scheduler} from 'node:timers/promises' import {test, expect} from '@jest/globals' import {renderHookToSnapshotStream} from '@testing-library/react-render-stream' import * as React from 'react' @@ -33,7 +34,7 @@ test('basic functionality', async () => { initialProps: 'initial', }) testEvents.emit('rerenderWithValue', 'value') - await Promise.resolve() + await scheduler.wait(10) testEvents.emit('rerenderWithValue', 'value2') { const snapshot = await takeSnapshot() From c064440a68d52b3ee14d279c92c49fb04286dac2 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 13:25:55 +0100 Subject: [PATCH 14/37] add comment --- src/disableActEnvironment.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/disableActEnvironment.ts b/src/disableActEnvironment.ts index 392ef66ac..63c0b11b1 100644 --- a/src/disableActEnvironment.ts +++ b/src/disableActEnvironment.ts @@ -31,6 +31,9 @@ const dispose: typeof Symbol.dispose = * * cleanup(); * }) + * + * For more context on what `act` is and why you shouldn't use it in renderStream tests, + * https://github.com/reactwg/react-18/discussions/102 is probably the best resource we have. */ export function disableActEnvironment(): {cleanup: () => void} & Disposable { const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} From 932f93885e08b9eeafdd005b036ae44238a0d601 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 13:33:38 +0100 Subject: [PATCH 15/37] guard in tests against accidental `IS_REACT_ACT_ENVIRONMENT` --- tests/setup-env.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/setup-env.js b/tests/setup-env.js index 24c394c47..d3ba8c0ab 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1 +1,15 @@ import './polyfill.js' + +Object.defineProperty(global, 'IS_REACT_ACT_ENVIRONMENT', { + get() { + return false + }, + set(value) { + if (!!value) { + throw new Error( + 'Cannot set IS_REACT_ACT_ENVIRONMENT to true, this probably pulled in some RTL dependency?', + ) + } + }, + configurable: true, +}) From 451d67a3a904307f56362888b06a0abacda11baf Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 13:55:14 +0100 Subject: [PATCH 16/37] drain the microtask queue before returning from `peekRender` --- src/renderStream/createRenderStream.tsx | 27 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 50d9b7ed1..50a1a06a4 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -279,18 +279,27 @@ export function createRenderStream< return stream.renders.length }, async peekRender(options: NextRenderOptions = {}) { - if (iteratorPosition < stream.renders.length) { - const peekedRender = stream.renders[iteratorPosition] + try { + if (iteratorPosition < stream.renders.length) { + const peekedRender = stream.renders[iteratorPosition] - if (peekedRender.phase === 'snapshotError') { - throw peekedRender.error - } + if (peekedRender.phase === 'snapshotError') { + throw peekedRender.error + } - return peekedRender + return peekedRender + } + return await stream + .waitForNextRender(options) + .catch(rethrowWithCapturedStackTrace(stream.peekRender)) + } finally { + /** drain microtask queue */ + await new Promise(resolve => { + setTimeout(() => { + resolve() + }, 0) + }) } - return stream - .waitForNextRender(options) - .catch(rethrowWithCapturedStackTrace(stream.peekRender)) }, takeRender: markAssertable(async function takeRender( options: NextRenderOptions = {}, From 53d9f45ae5f69af05839864c6b5bc0b31f86ae01 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 29 Nov 2024 15:15:12 +0100 Subject: [PATCH 17/37] directly import from `@testing-library/dom` where possible --- src/renderToRenderStream.ts | 2 +- src/renderWithoutAct.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 493de118d..75a868dee 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -1,8 +1,8 @@ import { - Queries, type RenderOptions as BaseOptions, type RenderResult as BaseResult, } from '@testing-library/react/pure.js' +import {type Queries} from '@testing-library/dom' import { createRenderStream, type RenderStreamOptions, diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index aa06f17f5..35acf9e98 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -1,12 +1,14 @@ import * as ReactDOMClient from 'react-dom/client' import * as ReactDOM from 'react-dom' import { - getQueriesForElement, - prettyDOM, - Queries, type RenderOptions, type RenderResult, } from '@testing-library/react/pure.js' +import { + getQueriesForElement, + prettyDOM, + type Queries, +} from '@testing-library/dom' import React from 'react' import {SyncQueries} from './renderStream/syncQueries.js' import {withDisabledActEnvironment} from './withDisabledActEnvironment.js' From 32daf10c4bbe168be4229a389ddd444b47081dbd Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 10:53:55 +0100 Subject: [PATCH 18/37] move `useWithoutAct` into `disableActEnvironment`, reduce api size --- src/__tests__/renderToRenderStream.test.tsx | 9 +- src/disableActEnvironment.ts | 118 +++++++++++++++++- src/pure.ts | 7 +- .../__tests__/createRenderStream.test.tsx | 9 +- src/renderWithoutAct.tsx | 16 ++- src/useWithoutAct.ts | 38 ------ src/withDisabledActEnvironment.ts | 14 --- 7 files changed, 134 insertions(+), 77 deletions(-) delete mode 100644 src/useWithoutAct.ts delete mode 100644 src/withDisabledActEnvironment.ts diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index d5b3ee348..b8f2d3b2b 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -1,14 +1,9 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import {describe, test, expect} from '@jest/globals' -import { - renderToRenderStream, - userEventWithoutAct, -} from '@testing-library/react-render-stream' -import {userEvent as baseUserEvent} from '@testing-library/user-event' +import {renderToRenderStream} from '@testing-library/react-render-stream' +import {userEvent} from '@testing-library/user-event' import * as React from 'react' -const userEvent = userEventWithoutAct(baseUserEvent) - function CounterForm({ value, onIncrement, diff --git a/src/disableActEnvironment.ts b/src/disableActEnvironment.ts index 63c0b11b1..938d88736 100644 --- a/src/disableActEnvironment.ts +++ b/src/disableActEnvironment.ts @@ -1,10 +1,39 @@ +import {getConfig} from '@testing-library/dom' + const dispose: typeof Symbol.dispose = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition Symbol.dispose ?? Symbol.for('nodejs.dispose') +export interface DisableActEnvironmentOptions { + /** + * If `true`, all modifications of values set by `disableActEnvironment` + * will be prevented until `cleanup` is called. + * + * @default true + */ + preventModification?: boolean + + /** + * If `true`, will change the configuration of the testing library to + * prevent auto-wrapping e.g. `userEvent` calls in `act`. + * + * @default true + */ + adjustTestingLibConfig?: boolean +} + /** * Helper to temporarily disable a React 18+ act environment. * + * By default, this also adjusts the configuration of @testing-library/dom + * to prevent auto-wrapping of user events in `act`, as well as preventing + * all modifications of values set by this method until `cleanup` is called + * or the returned `Disposable` is disposed of. + * + * Both of these behaviors can be disabled with the option, of the defaults + * can be changed for all calls to this method by modifying + * `disableActEnvironment.defaultOptions`. + * * This returns a disposable and can be used in combination with `using` to * automatically restore the state from before this method call after your test. * @@ -35,16 +64,95 @@ const dispose: typeof Symbol.dispose = * For more context on what `act` is and why you shouldn't use it in renderStream tests, * https://github.com/reactwg/react-18/discussions/102 is probably the best resource we have. */ -export function disableActEnvironment(): {cleanup: () => void} & Disposable { - const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} - const prevActEnv = anyThis.IS_REACT_ACT_ENVIRONMENT - anyThis.IS_REACT_ACT_ENVIRONMENT = false +export function disableActEnvironment({ + preventModification = disableActEnvironment.defaultOptions + .preventModification, + adjustTestingLibConfig = disableActEnvironment.defaultOptions + .adjustTestingLibConfig, +}: DisableActEnvironmentOptions = {}): {cleanup: () => void} & Disposable { + const typedGlobal = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} + const cleanupFns: Array<() => void> = [] + + // core functionality + { + const previous = typedGlobal.IS_REACT_ACT_ENVIRONMENT + cleanupFns.push(() => { + Object.defineProperty(typedGlobal, 'IS_REACT_ACT_ENVIRONMENT', { + value: previous, + }) + }) + Object.defineProperty( + typedGlobal, + 'IS_REACT_ACT_ENVIRONMENT', + getNewPropertyDescriptor(false, preventModification), + ) + } + + if (adjustTestingLibConfig) { + const config = getConfig() + // eslint-disable-next-line @typescript-eslint/unbound-method + const {asyncWrapper, eventWrapper} = config + cleanupFns.push(() => { + Object.defineProperty(config, 'asyncWrapper', {value: asyncWrapper}) + Object.defineProperty(config, 'eventWrapper', {value: eventWrapper}) + }) + + Object.defineProperty( + config, + 'asyncWrapper', + getNewPropertyDescriptor( + fn => fn(), + preventModification, + ), + ) + Object.defineProperty( + config, + 'eventWrapper', + getNewPropertyDescriptor( + fn => fn(), + preventModification, + ), + ) + } function cleanup() { - anyThis.IS_REACT_ACT_ENVIRONMENT = prevActEnv + while (cleanupFns.length > 0) { + cleanupFns.pop()!() + } } return { cleanup, [dispose]: cleanup, } } + +/** + * Default options for `disableActEnvironment`. + * + * This can be modified to change the default options for all calls to `disableActEnvironment`. + */ +disableActEnvironment.defaultOptions = { + preventModification: true, + adjustTestingLibConfig: true, +} satisfies Required as Required + +function getNewPropertyDescriptor( + value: T, + preventModification: boolean, +): PropertyDescriptor { + return preventModification + ? { + configurable: true, + enumerable: true, + get() { + return value + }, + set() {}, + } + : { + configurable: true, + enumerable: true, + writable: true, + value, + } +} diff --git a/src/pure.ts b/src/pure.ts index 5e2ce46b4..561001fcd 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -24,6 +24,7 @@ export { cleanup, type RenderWithoutActAsync, } from './renderWithoutAct.js' -export {userEventWithoutAct} from './useWithoutAct.js' -export {disableActEnvironment} from './disableActEnvironment.js' -export {withDisabledActEnvironment} from './withDisabledActEnvironment.js' +export { + disableActEnvironment, + type DisableActEnvironmentOptions, +} from './disableActEnvironment.js' diff --git a/src/renderStream/__tests__/createRenderStream.test.tsx b/src/renderStream/__tests__/createRenderStream.test.tsx index b515c7d64..e514b1915 100644 --- a/src/renderStream/__tests__/createRenderStream.test.tsx +++ b/src/renderStream/__tests__/createRenderStream.test.tsx @@ -1,16 +1,11 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import {jest, describe, test, expect} from '@jest/globals' -import { - createRenderStream, - userEventWithoutAct, -} from '@testing-library/react-render-stream' -import {userEvent as baseUserEvent} from '@testing-library/user-event' +import {createRenderStream} from '@testing-library/react-render-stream' import * as React from 'react' import {ErrorBoundary} from 'react-error-boundary' +import {userEvent} from '@testing-library/user-event' import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js' -const userEvent = userEventWithoutAct(baseUserEvent) - function CounterForm({ value, onIncrement, diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index 35acf9e98..96d00d304 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -11,7 +11,10 @@ import { } from '@testing-library/dom' import React from 'react' import {SyncQueries} from './renderStream/syncQueries.js' -import {withDisabledActEnvironment} from './withDisabledActEnvironment.js' +import { + disableActEnvironment, + DisableActEnvironmentOptions, +} from './disableActEnvironment.js' // Ideally we'd just use a WeakMap where containers are keys and roots are values. // We use two variables so that we can bail out in constant time when we render with a new container (most common use case) @@ -209,7 +212,12 @@ export function cleanup() { // there is a good chance this happens outside of a test, where the user // has no control over enabling or disabling the React Act environment, // so we do it for them here. - withDisabledActEnvironment(() => { + + const disabledAct = disableActEnvironment({ + preventModification: false, + adjustTestingLibConfig: false, + } satisfies /* ensure that all possible options are passed here in case we add more in the future */ Required) + try { mountedRootEntries.forEach(({root, container}) => { root.unmount() @@ -219,5 +227,7 @@ export function cleanup() { }) mountedRootEntries.length = 0 mountedContainers.clear() - }) + } finally { + disabledAct.cleanup() + } } diff --git a/src/useWithoutAct.ts b/src/useWithoutAct.ts deleted file mode 100644 index 5f290f1d4..000000000 --- a/src/useWithoutAct.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 - ? 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 eventWrapper = config.eventWrapper - // eslint-disable-next-line @typescript-eslint/unbound-method - const asyncWrapper = config.asyncWrapper - try { - config.eventWrapper = cb => cb() - // eslint-disable-next-line @typescript-eslint/return-await - return await (value as Function).apply(this, args) - } finally { - config.asyncWrapper = asyncWrapper - config.eventWrapper = eventWrapper - } - }, - ] - } - return [key, value] - }), - ) as AsyncUserEvent -} diff --git a/src/withDisabledActEnvironment.ts b/src/withDisabledActEnvironment.ts deleted file mode 100644 index e6b96098c..000000000 --- a/src/withDisabledActEnvironment.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {disableActEnvironment} from './disableActEnvironment.js' - -export function withDisabledActEnvironment(cb: () => T): T { - const disabledActWarnings = disableActEnvironment() - let result: T | undefined - try { - result = cb() - return result - } finally { - if (result != null && result instanceof Promise) { - void result.finally(disabledActWarnings.cleanup) - } else disabledActWarnings.cleanup() - } -} From 084b691a621f86198bd332c31c9a3f769843e6dd Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 11:05:26 +0100 Subject: [PATCH 19/37] keep `writable` at cleanup --- src/disableActEnvironment.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/disableActEnvironment.ts b/src/disableActEnvironment.ts index 938d88736..402f9b16d 100644 --- a/src/disableActEnvironment.ts +++ b/src/disableActEnvironment.ts @@ -79,6 +79,8 @@ export function disableActEnvironment({ cleanupFns.push(() => { Object.defineProperty(typedGlobal, 'IS_REACT_ACT_ENVIRONMENT', { value: previous, + writable: true, + configurable: true, }) }) Object.defineProperty( @@ -93,8 +95,16 @@ export function disableActEnvironment({ // eslint-disable-next-line @typescript-eslint/unbound-method const {asyncWrapper, eventWrapper} = config cleanupFns.push(() => { - Object.defineProperty(config, 'asyncWrapper', {value: asyncWrapper}) - Object.defineProperty(config, 'eventWrapper', {value: eventWrapper}) + Object.defineProperty(config, 'asyncWrapper', { + value: asyncWrapper, + writable: true, + configurable: true, + }) + Object.defineProperty(config, 'eventWrapper', { + value: eventWrapper, + writable: true, + configurable: true, + }) }) Object.defineProperty( From 563b934a8c1d4d0f1d60686fb6b694986f7a58b5 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 11:36:42 +0100 Subject: [PATCH 20/37] early bailout in `cleanup` --- src/renderWithoutAct.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index 96d00d304..aa1cec337 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -209,6 +209,11 @@ This is not supported. Please use \`disableActEnvironment\` to disable the act e } export function cleanup() { + if (!mountedRootEntries.length) { + // nothing to clean up + return + } + // there is a good chance this happens outside of a test, where the user // has no control over enabling or disabling the React Act environment, // so we do it for them here. @@ -218,13 +223,13 @@ export function cleanup() { adjustTestingLibConfig: false, } satisfies /* ensure that all possible options are passed here in case we add more in the future */ Required) try { - mountedRootEntries.forEach(({root, container}) => { + for (const {root, container} of mountedRootEntries) { root.unmount() if (container.parentNode === document.body) { document.body.removeChild(container) } - }) + } mountedRootEntries.length = 0 mountedContainers.clear() } finally { From 0e9d9d7ec2f04c5a080555b37b8b2f695fe2a5ce Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 11:37:59 +0100 Subject: [PATCH 21/37] keep `renderWithoutAct` private --- src/pure.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pure.ts b/src/pure.ts index 561001fcd..ea61ff2af 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -19,11 +19,7 @@ export type {SnapshotStream} from './renderHookToSnapshotStream.js' export type {Assertable} from './assertable.js' -export { - renderWithoutAct, - cleanup, - type RenderWithoutActAsync, -} from './renderWithoutAct.js' +export {cleanup} from './renderWithoutAct.js' export { disableActEnvironment, type DisableActEnvironmentOptions, From 94f35e59296c669e62230d3320f0c09a024e6d17 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 12:21:47 +0100 Subject: [PATCH 22/37] `renderToRenderStream` also should be async --- src/__tests__/renderToRenderStream.test.tsx | 22 +++++++-------------- src/disableActEnvironment.ts | 9 ++++++--- src/renderToRenderStream.ts | 17 ++++++++-------- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index b8f2d3b2b..0144c752b 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -33,13 +33,9 @@ describe('snapshotDOM', () => { ) } - const {takeRender, renderResultPromise} = renderToRenderStream( - , - { - snapshotDOM: true, - }, - ) - const utils = await renderResultPromise + const {takeRender, utils} = await renderToRenderStream(, { + snapshotDOM: true, + }) const incrementButton = utils.getByText('Increment') await userEvent.click(incrementButton) await userEvent.click(incrementButton) @@ -69,14 +65,10 @@ describe('snapshotDOM', () => { return null }, } - const {takeRender, renderResultPromise} = renderToRenderStream( - , - { - queries, - snapshotDOM: true, - }, - ) - const utils = await renderResultPromise + const {takeRender, utils} = await renderToRenderStream(, { + queries, + snapshotDOM: true, + }) expect(utils.foo()).toBe(null) const {withinDOM} = await takeRender() expect(withinDOM().foo()).toBe(null) diff --git a/src/disableActEnvironment.ts b/src/disableActEnvironment.ts index 402f9b16d..08503c29c 100644 --- a/src/disableActEnvironment.ts +++ b/src/disableActEnvironment.ts @@ -56,10 +56,13 @@ export interface DisableActEnvironmentOptions { * test("my test", () => { * const { cleanup } = disableActEnvironment(); * - * // your test code here - * - * cleanup(); + * try { + * // your test code here + * } finally { + * cleanup(); + * } * }) + * ``` * * For more context on what `act` is and why you shouldn't use it in renderStream tests, * https://github.com/reactwg/react-18/discussions/102 is probably the best resource we have. diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 75a868dee..166611cab 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -20,13 +20,13 @@ export interface RenderStreamWithRenderResult< Snapshot extends ValidSnapshot = void, Q extends Queries = SyncQueries, > extends RenderStream { - renderResultPromise: Promise> + utils: BaseResult } /** * Render into a container which is appended to document.body. It should be used with cleanup. */ -export function renderToRenderStream< +export async function renderToRenderStream< Snapshot extends ValidSnapshot = void, Q extends Queries = SyncQueries, >( @@ -39,7 +39,7 @@ export function renderToRenderStream< queries, ...options }: RenderOptions = {}, -): RenderStreamWithRenderResult { +): Promise> { const {render, ...stream} = createRenderStream({ onRender, snapshotDOM, @@ -47,11 +47,12 @@ export function renderToRenderStream< skipNonTrackingRenders, queries, }) - // `render` needs to be called asynchronously here, because the definition of `ui` + + // We need to wait a tick before calling `render` here, because the definition of `ui` // might contain components that reference the return value of `renderToRenderStream` // itself, e.g. `replaceSnapshot` or `mergeSnapshot`. - const renderResultPromise = Promise.resolve().then(() => - render(ui, {...options, queries}), - ) - return {...stream, renderResultPromise} + await Promise.resolve() + const utils = await render(ui, {...options, queries}) + + return {...stream, utils} } From 262cebae2df62861c73476b776362b03c5b3e6ad Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 14:21:42 +0100 Subject: [PATCH 23/37] make `rerender` wait for the render and return `Promise` --- .../__tests__/createRenderStream.test.tsx | 14 ++++ src/renderStream/createRenderStream.tsx | 10 ++- src/renderToRenderStream.ts | 3 +- src/renderWithoutAct.tsx | 82 +++++++++++-------- 4 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/renderStream/__tests__/createRenderStream.test.tsx b/src/renderStream/__tests__/createRenderStream.test.tsx index e514b1915..a15a52919 100644 --- a/src/renderStream/__tests__/createRenderStream.test.tsx +++ b/src/renderStream/__tests__/createRenderStream.test.tsx @@ -267,4 +267,18 @@ Expected: 1 Received: 2 `) }) + + test('returned `rerender` returns a promise that resolves', async () => { + function Component() { + return null + } + + const {takeRender, render} = createRenderStream() + const {rerender} = await render() + await takeRender() + const promise: Promise = rerender() + expect(promise).toBeInstanceOf(Promise) + await promise + await takeRender() + }) }) diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 50a1a06a4..6f2206c6e 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -251,7 +251,7 @@ export function createRenderStream< ui: React.ReactNode, options?: RenderOptions, ) => { - const ret = renderWithoutAct(ui, { + const ret = await renderWithoutAct(ui, { ...options, wrapper: props => { const ParentWrapper = options?.wrapper ?? React.Fragment @@ -265,6 +265,14 @@ export function createRenderStream< if (stream.renders.length === 0) { await stream.waitForNextRender() } + const origRerender = ret.rerender + ret.rerender = async function rerender(rerenderUi: React.ReactNode) { + try { + return await origRerender(rerenderUi) + } finally { + await stream.waitForNextRender() + } + } return ret }) as unknown as RenderWithoutActAsync // TODO diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 166611cab..662db2f25 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -10,6 +10,7 @@ import { type ValidSnapshot, } from './renderStream/createRenderStream.js' import {SyncQueries} from './renderStream/syncQueries.js' +import {AsyncRenderResult} from './renderWithoutAct.js' type RenderOptions< Snapshot extends ValidSnapshot = void, @@ -20,7 +21,7 @@ export interface RenderStreamWithRenderResult< Snapshot extends ValidSnapshot = void, Q extends Queries = SyncQueries, > extends RenderStream { - utils: BaseResult + utils: AsyncRenderResult } /** diff --git a/src/renderWithoutAct.tsx b/src/renderWithoutAct.tsx index aa1cec337..26e9d3cb5 100644 --- a/src/renderWithoutAct.tsx +++ b/src/renderWithoutAct.tsx @@ -1,12 +1,11 @@ import * as ReactDOMClient from 'react-dom/client' import * as ReactDOM from 'react-dom' +import {type RenderOptions} from '@testing-library/react/pure.js' import { - type RenderOptions, - type RenderResult, -} from '@testing-library/react/pure.js' -import { + BoundFunction, getQueriesForElement, prettyDOM, + prettyFormat, type Queries, } from '@testing-library/dom' import React from 'react' @@ -25,6 +24,26 @@ const mountedRootEntries: Array<{ root: ReturnType }> = [] +export type AsyncRenderResult< + Q extends Queries = SyncQueries, + Container extends ReactDOMClient.Container = HTMLElement, + BaseElement extends ReactDOMClient.Container = Container, +> = { + container: Container + baseElement: BaseElement + debug: ( + baseElement?: + | ReactDOMClient.Container + | Array + | undefined, + maxLength?: number | undefined, + options?: prettyFormat.OptionsReceived | undefined, + ) => void + rerender: (rerenderUi: React.ReactNode) => Promise + unmount: () => void + asFragment: () => DocumentFragment +} & {[P in keyof Q]: BoundFunction} + function renderRoot( ui: React.ReactNode, { @@ -38,7 +57,7 @@ function renderRoot( container: ReactDOMClient.Container root: ReturnType }, -): RenderResult { +): AsyncRenderResult<{}, any, any> { root.render( WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, ) @@ -57,7 +76,7 @@ function renderRoot( unmount: () => { root.unmount() }, - rerender: rerenderUi => { + rerender: async rerenderUi => { renderRoot(rerenderUi, { container, baseElement, @@ -80,7 +99,7 @@ function renderRoot( } }, ...getQueriesForElement(baseElement as HTMLElement, queries), - } as RenderResult // TODO clean up more + } } export type RenderWithoutActAsync = { @@ -91,18 +110,19 @@ export type RenderWithoutActAsync = { >( this: any, ui: React.ReactNode, - options: //Omit< - RenderOptions, - //'hydrate' | 'legacyRoot' >, - ): Promise> + options: Pick< + RenderOptions, + 'container' | 'baseElement' | 'queries' | 'wrapper' + >, + ): Promise> ( this: any, ui: React.ReactNode, options?: - | Omit + | Pick | undefined, ): Promise< - RenderResult< + AsyncRenderResult< SyncQueries, ReactDOMClient.Container, ReactDOMClient.Container @@ -110,35 +130,21 @@ export type RenderWithoutActAsync = { > } -export function renderWithoutAct< - Q extends Queries = SyncQueries, - Container extends ReactDOMClient.Container = HTMLElement, - BaseElement extends ReactDOMClient.Container = Container, ->( - ui: React.ReactNode, - options: //Omit< - RenderOptions, - //'hydrate' | 'legacyRoot' >, -): RenderResult -export function renderWithoutAct( - ui: React.ReactNode, - options?: - | Omit - | undefined, -): RenderResult +export const renderWithoutAct = + _renderWithoutAct as unknown as RenderWithoutActAsync -export function renderWithoutAct( +async function _renderWithoutAct( ui: React.ReactNode, { container, baseElement = container, queries, wrapper, - }: Omit< - RenderOptions, - 'hydrate' | 'legacyRoot' + }: Pick< + RenderOptions, + 'container' | 'baseElement' | 'wrapper' | 'queries' > = {}, -): RenderResult { +): Promise> { 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 @@ -172,7 +178,13 @@ export function renderWithoutAct( }) } - return renderRoot(ui, {baseElement, container, queries, wrapper, root: root!}) + return renderRoot(ui, { + baseElement, + container, + queries, + wrapper, + root: root!, + }) } function createLegacyRoot(container: ReactDOMClient.Container) { From c09b9974ffa8f16f724a734fffc57520b57df0c0 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 14:23:34 +0100 Subject: [PATCH 24/37] update README --- README.md | 100 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 7832a32ee..480f8ef03 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ## What is this library? -This library allows you to make render-per-render assertions on your React -components and hooks. This is usually not necessary, but can be highly -beneficial when testing hot code paths. +This library allows you to make committed-render-to-committed-render assertions +on your React components and hooks. This is usually not necessary, but can be +highly beneficial when testing hot code paths. ## Who is this library for? @@ -36,7 +36,7 @@ test('iterate through renders with DOM snapshots', async () => { const {takeRender, render} = createRenderStream({ snapshotDOM: true, }) - const utils = render() + const utils = await render() const incrementButton = utils.getByText('Increment') await userEvent.click(incrementButton) await userEvent.click(incrementButton) @@ -63,23 +63,19 @@ test('iterate through renders with DOM snapshots', async () => { In every place you would call ```js -const renderStream = createRenderStream(options) -const utils = renderStream.render(, options) +const {takeRender, render} = createRenderStream(options) +const utils = await render(, options) ``` you can also call ```js -const renderStream = renderToRenderStream(, combinedOptions) -// if required -const utils = await renderStream.renderResultPromise +const {takeRender, utils} = await renderToRenderStream( + , + combinedOptions, +) ``` -This might be shorter (especially in cases where you don't need to access -`utils`), but keep in mind that the render is executed **asynchronously** after -calling `renderToRenderStream`, and that you need to `await renderResultPromise` -if you need access to `utils` as returned by `render`. - ### `renderHookToSnapshotStream` Usage is very similar to RTL's `renderHook`, but you get a `snapshotStream` @@ -87,7 +83,7 @@ object back that you can iterate with `takeSnapshot` calls. ```jsx test('`useQuery` with `skip`', async () => { - const {takeSnapshot, rerender} = renderHookToSnapshotStream( + const {takeSnapshot, rerender} = await renderHookToSnapshotStream( ({skip}) => useQuery(query, {skip}), { wrapper: ({children}) => {children}, @@ -105,7 +101,7 @@ test('`useQuery` with `skip`', async () => { expect(result.data).toEqual({hello: 'world 1'}) } - rerender({skip: true}) + await rerender({skip: true}) { const snapshot = await takeSnapshot() expect(snapshot.loading).toBe(false) @@ -146,7 +142,7 @@ test('`useTrackRenders` with suspense', async () => { } const {takeRender, render} = createRenderStream() - render() + await render() { const {renderedComponents} = await takeRender() expect(renderedComponents).toEqual([App, LoadingComponent]) @@ -179,7 +175,7 @@ test('custom snapshots with `replaceSnapshot`', async () => { const {takeRender, replaceSnapshot, render} = createRenderStream<{ value: number }>() - const utils = render() + const utils = await render() const incrementButton = utils.getByText('Increment') await userEvent.click(incrementButton) { @@ -215,16 +211,14 @@ test('assertions in `onRender`', async () => { ) } - const {takeRender, replaceSnapshot, renderResultPromise} = - renderToRenderStream<{ - value: number - }>({ - onRender(info) { - // you can use `expect` here - expect(info.count).toBe(info.snapshot.value + 1) - }, - }) - const utils = await renderResultPromise + const {takeRender, replaceSnapshot, utils} = await renderToRenderStream<{ + value: number + }>({ + onRender(info) { + // you can use `expect` here + expect(info.count).toBe(info.snapshot.value + 1) + }, + }) const incrementButton = utils.getByText('Increment') await userEvent.click(incrementButton) await userEvent.click(incrementButton) @@ -247,7 +241,7 @@ This library adds to matchers to `expect` that can be used like ```tsx test('basic functionality', async () => { - const {takeRender} = renderToRenderStream() + const {takeRender} = await renderToRenderStream() await expect(takeRender).toRerender() await takeRender() @@ -285,17 +279,45 @@ await expect(snapshotStream).toRerender() > [!TIP] > > If you don't want these matchers not to be automatically installed, you can -> import from `@testing-library/react-render-stream` instead. +> import from `@testing-library/react-render-stream/pure` instead. +> Keep in mind that if you use the `/pure` import, you have to call the +> `cleanup` export manually after each test. + +## Usage side-by side with `@testing-library/react` or other tools that set `IS_REACT_ACT_ENVIRONMENT` or use `act` + +This library is written in a way if should not be used with `act`, and it will +throw an error if `IS_REACT_ACT_ENVIRONMENT` is `true`. + +React Testing Library usually sets `IS_REACT_ACT_ENVIRONMENT` to `true` +globally, and wraps some helpers like `userEvent.click` in `act` calls. + +To use this library side-by-side with React Testing Library, we ship the +`disableActEnvironment` helper to undo these changes temporarily. + +It returns a `Disposable` and can be used together with the `using` keyword to +automatically clean up once the scope is left: -## A note on `act`. +```ts +test('my test', () => { + using _disabledAct = disableActEnvironment() -You might want to avoid using this library with `act`, as `act` -[can end up batching multiple renders](https://github.com/facebook/react/issues/30031#issuecomment-2183951296) -into one in a way that would not happen in a production application. + // your test code here -While that is convenient in a normal test suite, it defeats the purpose of this -library. + // as soon as this scope is left, the environment will be cleaned up +}) +``` -Keep in mind that tools like `userEvent.click` use `act` internally. Many of -those calls would only trigger one render anyways, so it can be okay to use -them, but avoid this for longer-running actions inside of `act` calls. +If you cannot use `using`, you can also manually call the returned `cleanup` +function: + +```ts +test('my test', () => { + const {cleanup} = disableActEnvironment() + + try { + // your test code here + } finally { + cleanup() + } +}) +``` From 200d9d2059103f10608437865cdbda6a13164ed0 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 14:25:00 +0100 Subject: [PATCH 25/37] remove unused type --- src/renderToRenderStream.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 662db2f25..510a44a84 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -1,7 +1,4 @@ -import { - type RenderOptions as BaseOptions, - type RenderResult as BaseResult, -} from '@testing-library/react/pure.js' +import {type RenderOptions as BaseOptions} from '@testing-library/react/pure.js' import {type Queries} from '@testing-library/dom' import { createRenderStream, From 0cf8f41d682757694ba1c4d3fa86078d46e99d10 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 14:42:05 +0100 Subject: [PATCH 26/37] undo `renderToRenderStream` changes --- README.md | 12 +++++++---- src/__tests__/renderToRenderStream.test.tsx | 23 ++++++++++++++------- src/renderToRenderStream.ts | 17 +++++++-------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 480f8ef03..6b1383c2c 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,16 @@ const utils = await render(, options) you can also call ```js -const {takeRender, utils} = await renderToRenderStream( - , - combinedOptions, -) +const renderStream = renderToRenderStream(, combinedOptions) +// if required +const utils = await renderStream.renderResultPromise ``` +This might be shorter (especially in cases where you don't need to access +`utils`), but keep in mind that the render is executed **asynchronously** after +calling `renderToRenderStream`, and that you need to `await renderResultPromise` +if you need access to `utils` as returned by `render`. + ### `renderHookToSnapshotStream` Usage is very similar to RTL's `renderHook`, but you get a `snapshotStream` diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index 0144c752b..195ed6ad4 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -3,7 +3,6 @@ import {describe, test, expect} from '@jest/globals' import {renderToRenderStream} from '@testing-library/react-render-stream' import {userEvent} from '@testing-library/user-event' import * as React from 'react' - function CounterForm({ value, onIncrement, @@ -33,9 +32,13 @@ describe('snapshotDOM', () => { ) } - const {takeRender, utils} = await renderToRenderStream(, { - snapshotDOM: true, - }) + const {takeRender, renderResultPromise} = renderToRenderStream( + , + { + snapshotDOM: true, + }, + ) + const utils = await renderResultPromise const incrementButton = utils.getByText('Increment') await userEvent.click(incrementButton) await userEvent.click(incrementButton) @@ -65,10 +68,14 @@ describe('snapshotDOM', () => { return null }, } - const {takeRender, utils} = await renderToRenderStream(, { - queries, - snapshotDOM: true, - }) + const {takeRender, renderResultPromise} = renderToRenderStream( + , + { + queries, + snapshotDOM: true, + }, + ) + const utils = await renderResultPromise expect(utils.foo()).toBe(null) const {withinDOM} = await takeRender() expect(withinDOM().foo()).toBe(null) diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 510a44a84..afcf10386 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -18,13 +18,13 @@ export interface RenderStreamWithRenderResult< Snapshot extends ValidSnapshot = void, Q extends Queries = SyncQueries, > extends RenderStream { - utils: AsyncRenderResult + renderResultPromise: Promise> } /** * Render into a container which is appended to document.body. It should be used with cleanup. */ -export async function renderToRenderStream< +export function renderToRenderStream< Snapshot extends ValidSnapshot = void, Q extends Queries = SyncQueries, >( @@ -37,7 +37,7 @@ export async function renderToRenderStream< queries, ...options }: RenderOptions = {}, -): Promise> { +): RenderStreamWithRenderResult { const {render, ...stream} = createRenderStream({ onRender, snapshotDOM, @@ -45,12 +45,11 @@ export async function renderToRenderStream< skipNonTrackingRenders, queries, }) - - // We need to wait a tick before calling `render` here, because the definition of `ui` + // `render` needs to be called asynchronously here, because the definition of `ui` // might contain components that reference the return value of `renderToRenderStream` // itself, e.g. `replaceSnapshot` or `mergeSnapshot`. - await Promise.resolve() - const utils = await render(ui, {...options, queries}) - - return {...stream, utils} + const renderResultPromise = Promise.resolve().then(() => + render(ui, {...options, queries}), + ) + return {...stream, renderResultPromise} } From 20601bcc8dd54b8727ec8608b22916b20af7ab93 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 15:13:33 +0100 Subject: [PATCH 27/37] add type export back --- src/pure.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pure.ts b/src/pure.ts index ea61ff2af..fd6d7f010 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -19,7 +19,10 @@ export type {SnapshotStream} from './renderHookToSnapshotStream.js' export type {Assertable} from './assertable.js' -export {cleanup} from './renderWithoutAct.js' +export { + cleanup, + type RenderWithoutActAsync as AsyncRenderFn, +} from './renderWithoutAct.js' export { disableActEnvironment, type DisableActEnvironmentOptions, From e1cb39d6a2aad261f4dc9ed1c4fc691d5935e3e1 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 16:32:40 +0100 Subject: [PATCH 28/37] update type --- src/renderHookToSnapshotStream.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderHookToSnapshotStream.tsx b/src/renderHookToSnapshotStream.tsx index 258ad470b..ecf62d382 100644 --- a/src/renderHookToSnapshotStream.tsx +++ b/src/renderHookToSnapshotStream.tsx @@ -41,7 +41,7 @@ export interface SnapshotStream extends Assertable { * Does not advance the render iterator. */ waitForNextSnapshot(options?: NextRenderOptions): Promise - rerender: (rerenderCallbackProps: Props) => void + rerender: (rerenderCallbackProps: Props) => Promise unmount: () => void } From fab8705f217bcf8eb0f9fd2ec8ff594c36efad09 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 2 Dec 2024 16:53:11 +0100 Subject: [PATCH 29/37] guard against sync rerenders --- src/renderStream/createRenderStream.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 6f2206c6e..07cb87939 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -267,10 +267,15 @@ export function createRenderStream< } const origRerender = ret.rerender ret.rerender = async function rerender(rerenderUi: React.ReactNode) { + const previousRenderCount = stream.renders.length try { return await origRerender(rerenderUi) } finally { - await stream.waitForNextRender() + // only wait for the next render if the rerender was not + // synchronous (React 17) + if (previousRenderCount === stream.renders.length) { + await stream.waitForNextRender() + } } } return ret From 04c222de7679c2d55e7ca4bce474b7cae1e3aa9c Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 3 Dec 2024 09:56:31 +0100 Subject: [PATCH 30/37] remove `renderToRenderStream` --- README.md | 22 ----- src/__tests__/renderToRenderStream.test.tsx | 94 --------------------- src/pure.ts | 2 - src/renderToRenderStream.ts | 55 ------------ 4 files changed, 173 deletions(-) delete mode 100644 src/__tests__/renderToRenderStream.test.tsx delete mode 100644 src/renderToRenderStream.ts diff --git a/README.md b/README.md index 6b1383c2c..79c1164b2 100644 --- a/README.md +++ b/README.md @@ -58,28 +58,6 @@ test('iterate through renders with DOM snapshots', async () => { }) ``` -### `renderToRenderStream` as a shortcut for `createRenderStream` and calling `render` - -In every place you would call - -```js -const {takeRender, render} = createRenderStream(options) -const utils = await render(, options) -``` - -you can also call - -```js -const renderStream = renderToRenderStream(, combinedOptions) -// if required -const utils = await renderStream.renderResultPromise -``` - -This might be shorter (especially in cases where you don't need to access -`utils`), but keep in mind that the render is executed **asynchronously** after -calling `renderToRenderStream`, and that you need to `await renderResultPromise` -if you need access to `utils` as returned by `render`. - ### `renderHookToSnapshotStream` Usage is very similar to RTL's `renderHook`, but you get a `snapshotStream` diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx deleted file mode 100644 index 195ed6ad4..000000000 --- a/src/__tests__/renderToRenderStream.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* 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 * as React from 'react' -function CounterForm({ - value, - onIncrement, -}: { - value: number - onIncrement: () => void -}) { - return ( -
- - -
- ) -} - -describe('snapshotDOM', () => { - test('basic functionality', async () => { - function Counter() { - const [value, setValue] = React.useState(0) - return ( - setValue(v => v + 1)} /> - ) - } - - const {takeRender, renderResultPromise} = renderToRenderStream( - , - { - snapshotDOM: true, - }, - ) - 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('Value') - expect(input.value).toBe('0') - } - { - const {withinDOM} = await takeRender() - const input = withinDOM().getByLabelText('Value') - expect(input.value).toBe('1') - } - { - const {withinDOM} = await takeRender() - const input = withinDOM().getByLabelText('Value') - expect(input.value).toBe('2') - } - }) - - test('queries option', async () => { - function Component() { - return null - } - const queries = { - foo: (_: any) => { - return null - }, - } - const {takeRender, renderResultPromise} = renderToRenderStream( - , - { - queries, - snapshotDOM: true, - }, - ) - const utils = await renderResultPromise - expect(utils.foo()).toBe(null) - const {withinDOM} = await takeRender() - expect(withinDOM().foo()).toBe(null) - function _typeTest() { - // @ts-expect-error should not be present - utils.getByText - // @ts-expect-error should not be present - withinDOM().getByText - utils.debug() - withinDOM().debug() - const _str: string = withinDOM().logTestingPlaygroundURL() - } - }) -}) - -// for more tests, see the `createRenderStream` test suite, as `renderToRenderStream` is just a wrapper around that diff --git a/src/pure.ts b/src/pure.ts index fd6d7f010..7e1dce71f 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -12,8 +12,6 @@ export {useTrackRenders} from './renderStream/useTrackRenders.js' export type {SyncScreen} from './renderStream/Render.js' -export {renderToRenderStream} from './renderToRenderStream.js' -export type {RenderStreamWithRenderResult} from './renderToRenderStream.js' export {renderHookToSnapshotStream} from './renderHookToSnapshotStream.js' export type {SnapshotStream} from './renderHookToSnapshotStream.js' diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts deleted file mode 100644 index afcf10386..000000000 --- a/src/renderToRenderStream.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {type RenderOptions as BaseOptions} from '@testing-library/react/pure.js' -import {type Queries} from '@testing-library/dom' -import { - createRenderStream, - type RenderStreamOptions, - type RenderStream, - type ValidSnapshot, -} from './renderStream/createRenderStream.js' -import {SyncQueries} from './renderStream/syncQueries.js' -import {AsyncRenderResult} from './renderWithoutAct.js' - -type RenderOptions< - Snapshot extends ValidSnapshot = void, - Q extends Queries = SyncQueries, -> = BaseOptions & RenderStreamOptions - -export interface RenderStreamWithRenderResult< - Snapshot extends ValidSnapshot = void, - Q extends Queries = SyncQueries, -> extends RenderStream { - renderResultPromise: Promise> -} - -/** - * Render into a container which is appended to document.body. It should be used with cleanup. - */ -export function renderToRenderStream< - Snapshot extends ValidSnapshot = void, - Q extends Queries = SyncQueries, ->( - ui: React.ReactNode, - { - onRender, - snapshotDOM, - initialSnapshot, - skipNonTrackingRenders, - queries, - ...options - }: RenderOptions = {}, -): RenderStreamWithRenderResult { - const {render, ...stream} = createRenderStream({ - onRender, - snapshotDOM, - initialSnapshot, - skipNonTrackingRenders, - queries, - }) - // `render` needs to be called asynchronously here, because the definition of `ui` - // might contain components that reference the return value of `renderToRenderStream` - // itself, e.g. `replaceSnapshot` or `mergeSnapshot`. - const renderResultPromise = Promise.resolve().then(() => - render(ui, {...options, queries}), - ) - return {...stream, renderResultPromise} -} From 324ae8898607a0ce226ab9b997fd1d6bc35f209c Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 3 Dec 2024 14:32:22 +0100 Subject: [PATCH 31/37] add lint pr job --- .github/workflows/lint-pr.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/lint-pr.yml diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 000000000..2397ce74a --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,29 @@ +name: 'Lint PR' + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + requireScope: false + # If the PR only contains a single commit, the action will validate that + # it matches the configured pattern. + validateSingleCommit: true + # Related to `validateSingleCommit` you can opt-in to validate that the PR + # title matches a single commit to avoid confusion. + validateSingleCommitMatchesPrTitle: true From e1eae0459ec22983cae7ee8db78bc6ed838a2c9d Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 3 Dec 2024 14:37:25 +0100 Subject: [PATCH 32/37] avoid uncaught promise rejection in test --- src/renderStream/__tests__/useTrackRenders.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderStream/__tests__/useTrackRenders.test.tsx b/src/renderStream/__tests__/useTrackRenders.test.tsx index 86d65c334..c05c90ba6 100644 --- a/src/renderStream/__tests__/useTrackRenders.test.tsx +++ b/src/renderStream/__tests__/useTrackRenders.test.tsx @@ -17,6 +17,9 @@ describe('non-suspense use cases', () => { let asyncAction = Promise.withResolvers() beforeEach(() => { asyncAction = Promise.withResolvers() + void asyncAction.promise.catch(() => { + /* avoid uncaught promise rejection */ + }) }) function ErrorComponent() { useTrackRenders() From 7ea9591a538d48ef7d76a1097f23bb30c7141b43 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 3 Dec 2024 15:36:13 +0100 Subject: [PATCH 33/37] run tests with React 19 RC1 --- package.json | 4 ++-- yarn.lock | 39 +++++++++++++++++---------------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index ca6237a4a..0c9fa678f 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,8 @@ "pkg-pr-new": "^0.0.29", "prettier": "^3.3.3", "publint": "^0.2.11", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "19.0.0-rc.1", + "react-dom": "19.0.0-rc.1", "react-error-boundary": "^4.0.13", "ts-jest-resolver": "^2.0.1", "tsup": "^8.3.0", diff --git a/yarn.lock b/yarn.lock index 28d19d6f1..9f9465c0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2634,8 +2634,8 @@ __metadata: pkg-pr-new: "npm:^0.0.29" prettier: "npm:^3.3.3" publint: "npm:^0.2.11" - react: "npm:^18.3.1" - react-dom: "npm:^18.3.1" + react: "npm:19.0.0-rc.1" + react-dom: "npm:19.0.0-rc.1" react-error-boundary: "npm:^4.0.13" rehackt: "npm:^0.1.0" ts-jest-resolver: "npm:^2.0.1" @@ -7769,7 +7769,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -9169,15 +9169,14 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^18.3.1": - version: 18.3.1 - resolution: "react-dom@npm:18.3.1" +"react-dom@npm:19.0.0-rc.1": + version: 19.0.0-rc.1 + resolution: "react-dom@npm:19.0.0-rc.1" dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.2" + scheduler: "npm:0.25.0-rc.1" peerDependencies: - react: ^18.3.1 - checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85 + react: 19.0.0-rc.1 + checksum: 10c0/26fba423f41c8b3c7a47278ea490146506333175028e111e49eb39310110b6bda956cc772c77412abc8a7f28244eea80ebade15ec5b4382d79e2ac1b39bd2d0c languageName: node linkType: hard @@ -9213,12 +9212,10 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.3.1": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3 +"react@npm:19.0.0-rc.1": + version: 19.0.0-rc.1 + resolution: "react@npm:19.0.0-rc.1" + checksum: 10c0/8f453ee0ff05ec4b11701cf4240fb0039217e73f576d2a9b871a91e34ff17d66e16294ebd0fc72c237918a3a997cc42f0328c860bc0a2cfa18e6a41f49a1c871 languageName: node linkType: hard @@ -9712,12 +9709,10 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.2": - version: 0.23.2 - resolution: "scheduler@npm:0.23.2" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78 +"scheduler@npm:0.25.0-rc.1": + version: 0.25.0-rc.1 + resolution: "scheduler@npm:0.25.0-rc.1" + checksum: 10c0/dd4549eeb54cf3019c04257c622c4bbee12ef99dc547c4a96e1d5da8985ae44534111275ebcc4fea84b0d1299197b69071f912928adc5da4cab1e8168d09a44d languageName: node linkType: hard From 7ede4259c36dbb7f0375977f39a41aeb41888f2c Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 3 Dec 2024 15:37:50 +0100 Subject: [PATCH 34/37] use `use` over the shim when available --- src/__testHelpers__/useShim.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/__testHelpers__/useShim.js b/src/__testHelpers__/useShim.js index efeb62e68..115a7f93e 100644 --- a/src/__testHelpers__/useShim.js +++ b/src/__testHelpers__/useShim.js @@ -1,3 +1,5 @@ +import * as React from 'react' + /* eslint-disable default-case */ /* eslint-disable consistent-return */ function isStatefulPromise(promise) { @@ -33,7 +35,7 @@ function wrapPromiseWithState(promise) { * @param {Promise} promise * @returns {T} */ -export function __use(promise) { +function _use(promise) { const statefulPromise = wrapPromiseWithState(promise) switch (statefulPromise.status) { case 'pending': @@ -44,3 +46,5 @@ export function __use(promise) { return statefulPromise.value } } + +export const __use = /** @type {{use?: typeof _use}} */ (React).use || _use From f3fb67de787dc33dedce78db8b66c9ac6992b5c9 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 4 Dec 2024 09:56:15 +0100 Subject: [PATCH 35/37] Apply suggestions from code review Co-authored-by: Jerel Miller --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 79c1164b2..5780bc6f0 100644 --- a/README.md +++ b/README.md @@ -265,12 +265,12 @@ await expect(snapshotStream).toRerender() > Keep in mind that if you use the `/pure` import, you have to call the > `cleanup` export manually after each test. -## Usage side-by side with `@testing-library/react` or other tools that set `IS_REACT_ACT_ENVIRONMENT` or use `act` +## Usage side-by side with `@testing-library/react` or other tools that use `act` or set `IS_REACT_ACT_ENVIRONMENT` -This library is written in a way if should not be used with `act`, and it will +This library should not be used with `act`, and it will throw an error if `IS_REACT_ACT_ENVIRONMENT` is `true` throw an error if `IS_REACT_ACT_ENVIRONMENT` is `true`. -React Testing Library usually sets `IS_REACT_ACT_ENVIRONMENT` to `true` +React Testing Library sets `IS_REACT_ACT_ENVIRONMENT` to `true` globally, and wraps some helpers like `userEvent.click` in `act` calls. To use this library side-by-side with React Testing Library, we ship the From b0d022ff83f9d0934def071cfc8d8e7c314fdeff Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 4 Dec 2024 09:46:31 +0100 Subject: [PATCH 36/37] adjust import --- src/renderStream/createRenderStream.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index 07cb87939..e6518146d 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -2,7 +2,10 @@ import * as React from 'rehackt' import {type RenderOptions} from '@testing-library/react/pure.js' import {Assertable, markAssertable} from '../assertable.js' -import {renderWithoutAct, RenderWithoutActAsync} from '../renderWithoutAct.js' +import { + renderWithoutAct, + type RenderWithoutActAsync, +} from '../renderWithoutAct.js' import {RenderInstance, type Render, type BaseRender} from './Render.js' import {type RenderStreamContextValue} from './context.js' import {RenderStreamContextProvider} from './context.js' From 4d2345b6c5e725be01d41d1452c089a477c470c6 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 4 Dec 2024 10:10:01 +0100 Subject: [PATCH 37/37] review suggestions --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5780bc6f0..b89004303 100644 --- a/README.md +++ b/README.md @@ -267,17 +267,17 @@ await expect(snapshotStream).toRerender() ## Usage side-by side with `@testing-library/react` or other tools that use `act` or set `IS_REACT_ACT_ENVIRONMENT` -This library should not be used with `act`, and it will throw an error if `IS_REACT_ACT_ENVIRONMENT` is `true` -throw an error if `IS_REACT_ACT_ENVIRONMENT` is `true`. - -React Testing Library sets `IS_REACT_ACT_ENVIRONMENT` to `true` -globally, and wraps some helpers like `userEvent.click` in `act` calls. +This library should not be used with `act`, and it will throw an error if +`IS_REACT_ACT_ENVIRONMENT` is `true`. +React Testing Library sets `IS_REACT_ACT_ENVIRONMENT` to `true` globally, and +wraps some helpers like `userEvent.click` in `act` calls. To use this library side-by-side with React Testing Library, we ship the `disableActEnvironment` helper to undo these changes temporarily. -It returns a `Disposable` and can be used together with the `using` keyword to -automatically clean up once the scope is left: +It returns a `Disposable` and can be used together with the +[`using` keyword](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) +to automatically clean up once the scope is left: ```ts test('my test', () => { @@ -290,7 +290,8 @@ test('my test', () => { ``` If you cannot use `using`, you can also manually call the returned `cleanup` -function: +function. We recommend using `finally` to ensure the act environment is cleaned +up if your test fails, otherwise it could leak between tests: ```ts test('my test', () => {