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() + }, + } +}