Skip to content

Commit

Permalink
add renderWithoutAct helper
Browse files Browse the repository at this point in the history
  • Loading branch information
phryneas committed Nov 26, 2024
1 parent c4b152e commit ddf9df9
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 4 deletions.
2 changes: 2 additions & 0 deletions src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
9 changes: 5 additions & 4 deletions src/renderStream/createRenderStream.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -81,7 +82,7 @@ export interface RenderStreamWithRenderFn<
Snapshot extends ValidSnapshot,
Q extends Queries = SyncQueries,
> extends RenderStream<Snapshot, Q> {
render: typeof baseRender
render: typeof renderWithoutAct
}

export type RenderStreamOptions<
Expand Down Expand Up @@ -251,7 +252,7 @@ export function createRenderStream<
ui: React.ReactNode,
options?: RenderOptions<any, any, any>,
) => {
return baseRender(ui, {
return renderWithoutAct(ui, {
...options,
wrapper: props => {
const ParentWrapper = options?.wrapper ?? React.Fragment
Expand All @@ -262,7 +263,7 @@ export function createRenderStream<
)
},
})
}) as typeof baseRender
}) as typeof renderWithoutAct

Object.assign<typeof stream, typeof stream>(stream, {
replaceSnapshot,
Expand Down
152 changes: 152 additions & 0 deletions src/renderStream/renderWithoutAct.tsx
Original file line number Diff line number Diff line change
@@ -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<import('react-dom').Container> = new Set()
const mountedRootEntries: Array<{
container: import('react-dom').Container
root: ReturnType<typeof createConcurrentRoot>
}> = []

function renderRoot(
ui: React.ReactNode,
{
baseElement,
container,
queries,
wrapper: WrapperComponent,
root,
}: Pick<RenderOptions<Queries>, 'queries' | 'wrapper'> & {
baseElement: ReactDOMClient.Container
container: ReactDOMClient.Container
root: ReturnType<typeof createConcurrentRoot>
},
): RenderResult<Queries, any, any> {
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<Queries>(baseElement as HTMLElement, queries),
} as RenderResult<Queries, any, any> // 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<Q, Container, BaseElement>,
//'hydrate' | 'legacyRoot' >,
): RenderResult<Q, Container, BaseElement>
export function renderWithoutAct(
ui: React.ReactNode,
options?:
| Omit<RenderOptions, 'hydrate' | 'legacyRoot' | 'queries'>
| undefined,
): RenderResult<Queries, ReactDOMClient.Container, ReactDOMClient.Container>

export function renderWithoutAct(
ui: React.ReactNode,
{
container,
baseElement = container,
queries,
wrapper,
}: Omit<
RenderOptions<Queries, ReactDOMClient.Container, ReactDOMClient.Container>,
'hydrate' | 'legacyRoot'
> = {},
): RenderResult<any, ReactDOMClient.Container, ReactDOMClient.Container> {
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<typeof createConcurrentRoot>
// 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()
},
}
}

0 comments on commit ddf9df9

Please sign in to comment.