Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ddf9df9

Browse files
committedNov 26, 2024·
add renderWithoutAct helper
1 parent c4b152e commit ddf9df9

File tree

3 files changed

+159
-4
lines changed

3 files changed

+159
-4
lines changed
 

‎src/pure.ts

+2
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export {renderHookToSnapshotStream} from './renderHookToSnapshotStream.js'
1818
export type {SnapshotStream} from './renderHookToSnapshotStream.js'
1919

2020
export type {Assertable} from './assertable.js'
21+
22+
export {renderWithoutAct} from './renderStream/renderWithoutAct.js'

‎src/renderStream/createRenderStream.tsx

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import * as React from 'rehackt'
22

3-
import {render as baseRender, RenderOptions} from '@testing-library/react'
3+
import {RenderOptions} from '@testing-library/react'
44
import {Assertable, markAssertable} from '../assertable.js'
55
import {RenderInstance, type Render, type BaseRender} from './Render.js'
66
import {type RenderStreamContextValue} from './context.js'
77
import {RenderStreamContextProvider} from './context.js'
88
import {disableActWarnings} from './disableActWarnings.js'
99
import {syncQueries, type Queries, type SyncQueries} from './syncQueries.js'
10+
import {renderWithoutAct} from './renderWithoutAct.js'
1011

1112
export type ValidSnapshot =
1213
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
@@ -81,7 +82,7 @@ export interface RenderStreamWithRenderFn<
8182
Snapshot extends ValidSnapshot,
8283
Q extends Queries = SyncQueries,
8384
> extends RenderStream<Snapshot, Q> {
84-
render: typeof baseRender
85+
render: typeof renderWithoutAct
8586
}
8687

8788
export type RenderStreamOptions<
@@ -251,7 +252,7 @@ export function createRenderStream<
251252
ui: React.ReactNode,
252253
options?: RenderOptions<any, any, any>,
253254
) => {
254-
return baseRender(ui, {
255+
return renderWithoutAct(ui, {
255256
...options,
256257
wrapper: props => {
257258
const ParentWrapper = options?.wrapper ?? React.Fragment
@@ -262,7 +263,7 @@ export function createRenderStream<
262263
)
263264
},
264265
})
265-
}) as typeof baseRender
266+
}) as typeof renderWithoutAct
266267

267268
Object.assign<typeof stream, typeof stream>(stream, {
268269
replaceSnapshot,

‎src/renderStream/renderWithoutAct.tsx

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import * as ReactDOMClient from 'react-dom/client'
2+
import {
3+
getQueriesForElement,
4+
prettyDOM,
5+
Queries,
6+
type RenderOptions,
7+
type RenderResult,
8+
} from '@testing-library/react'
9+
import React from 'react'
10+
import {SyncQueries} from './syncQueries.js'
11+
12+
// Ideally we'd just use a WeakMap where containers are keys and roots are values.
13+
// We use two variables so that we can bail out in constant time when we render with a new container (most common use case)
14+
15+
const mountedContainers: Set<import('react-dom').Container> = new Set()
16+
const mountedRootEntries: Array<{
17+
container: import('react-dom').Container
18+
root: ReturnType<typeof createConcurrentRoot>
19+
}> = []
20+
21+
function renderRoot(
22+
ui: React.ReactNode,
23+
{
24+
baseElement,
25+
container,
26+
queries,
27+
wrapper: WrapperComponent,
28+
root,
29+
}: Pick<RenderOptions<Queries>, 'queries' | 'wrapper'> & {
30+
baseElement: ReactDOMClient.Container
31+
container: ReactDOMClient.Container
32+
root: ReturnType<typeof createConcurrentRoot>
33+
},
34+
): RenderResult<Queries, any, any> {
35+
root.render(
36+
WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui,
37+
)
38+
39+
return {
40+
container,
41+
baseElement,
42+
debug: (el = baseElement, maxLength, options) =>
43+
Array.isArray(el)
44+
? // eslint-disable-next-line no-console
45+
el.forEach(e =>
46+
console.log(prettyDOM(e as Element, maxLength, options)),
47+
)
48+
: // eslint-disable-next-line no-console,
49+
console.log(prettyDOM(el as Element, maxLength, options)),
50+
unmount: () => {
51+
root.unmount()
52+
},
53+
rerender: rerenderUi => {
54+
renderRoot(rerenderUi, {
55+
container,
56+
baseElement,
57+
root,
58+
wrapper: WrapperComponent,
59+
})
60+
// Intentionally do not return anything to avoid unnecessarily complicating the API.
61+
// folks can use all the same utilities we return in the first place that are bound to the container
62+
},
63+
asFragment: () => {
64+
/* istanbul ignore else (old jsdom limitation) */
65+
if (typeof document.createRange === 'function') {
66+
return document
67+
.createRange()
68+
.createContextualFragment((container as HTMLElement).innerHTML)
69+
} else {
70+
const template = document.createElement('template')
71+
template.innerHTML = (container as HTMLElement).innerHTML
72+
return template.content
73+
}
74+
},
75+
...getQueriesForElement<Queries>(baseElement as HTMLElement, queries),
76+
} as RenderResult<Queries, any, any> // TODO clean up more
77+
}
78+
79+
export function renderWithoutAct<
80+
Q extends Queries = SyncQueries,
81+
Container extends ReactDOMClient.Container = HTMLElement,
82+
BaseElement extends ReactDOMClient.Container = Container,
83+
>(
84+
ui: React.ReactNode,
85+
options: //Omit<
86+
RenderOptions<Q, Container, BaseElement>,
87+
//'hydrate' | 'legacyRoot' >,
88+
): RenderResult<Q, Container, BaseElement>
89+
export function renderWithoutAct(
90+
ui: React.ReactNode,
91+
options?:
92+
| Omit<RenderOptions, 'hydrate' | 'legacyRoot' | 'queries'>
93+
| undefined,
94+
): RenderResult<Queries, ReactDOMClient.Container, ReactDOMClient.Container>
95+
96+
export function renderWithoutAct(
97+
ui: React.ReactNode,
98+
{
99+
container,
100+
baseElement = container,
101+
queries,
102+
wrapper,
103+
}: Omit<
104+
RenderOptions<Queries, ReactDOMClient.Container, ReactDOMClient.Container>,
105+
'hydrate' | 'legacyRoot'
106+
> = {},
107+
): RenderResult<any, ReactDOMClient.Container, ReactDOMClient.Container> {
108+
if (!baseElement) {
109+
// default to document.body instead of documentElement to avoid output of potentially-large
110+
// head elements (such as JSS style blocks) in debug output
111+
baseElement = document.body
112+
}
113+
if (!container) {
114+
container = baseElement.appendChild(document.createElement('div'))
115+
}
116+
117+
let root: ReturnType<typeof createConcurrentRoot>
118+
// 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.
119+
if (!mountedContainers.has(container)) {
120+
root = createConcurrentRoot(container)
121+
122+
mountedRootEntries.push({container, root})
123+
// we'll add it to the mounted containers regardless of whether it's actually
124+
// added to document.body so the cleanup method works regardless of whether
125+
// they're passing us a custom container or not.
126+
mountedContainers.add(container)
127+
} else {
128+
mountedRootEntries.forEach(rootEntry => {
129+
// Else is unreachable since `mountedContainers` has the `container`.
130+
// Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries`
131+
/* istanbul ignore else */
132+
if (rootEntry.container === container) {
133+
root = rootEntry.root
134+
}
135+
})
136+
}
137+
138+
return renderRoot(ui, {baseElement, container, queries, wrapper, root: root!})
139+
}
140+
141+
function createConcurrentRoot(container: ReactDOMClient.Container) {
142+
const root = ReactDOMClient.createRoot(container)
143+
144+
return {
145+
render(element: React.ReactNode) {
146+
root.render(element)
147+
},
148+
unmount() {
149+
root.unmount()
150+
},
151+
}
152+
}

0 commit comments

Comments
 (0)
Please sign in to comment.