Skip to content

Commit 04e865c

Browse files
committed
React 18 compat
1 parent c1a931d commit 04e865c

File tree

5 files changed

+170
-36
lines changed

5 files changed

+170
-36
lines changed

src/__mocks__/pure.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as ReactDOM from 'react-dom'
2+
3+
const pure = jest.requireActual('../pure')
4+
5+
const originalRender = pure.render
6+
// Test concurrent react in the experimental release channel
7+
function possiblyConcurrentRender(ui, options) {
8+
return originalRender(ui, {
9+
concurrent: ReactDOM.version.includes('-experimental-'),
10+
...options,
11+
})
12+
}
13+
pure.render = possiblyConcurrentRender
14+
15+
module.exports = pure

src/__tests__/end-to-end.js

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class ComponentWithLoader extends React.Component {
3030
}
3131

3232
test('it waits for the data to be loaded', async () => {
33+
// TODO: discussions/23#discussioncomment-812450
34+
jest.useFakeTimers()
35+
3336
render(<ComponentWithLoader />)
3437
const loading = () => screen.getByText('Loading...')
3538
await waitForElementToBeRemoved(loading)

src/__tests__/render.js

+33
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,36 @@ test('flushes useEffect cleanup functions sync on unmount()', () => {
101101

102102
expect(spy).toHaveBeenCalledTimes(1)
103103
})
104+
105+
test('throws if `concurrent` is used with an incomaptible version', () => {
106+
const isConcurrentReact = typeof ReactDOM.createRoot === 'function'
107+
108+
const performConcurrentRender = () => render(<div />, {concurrent: true})
109+
110+
// eslint-disable-next-line jest/no-if -- jest doesn't support conditional tests
111+
if (isConcurrentReact) {
112+
// eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests
113+
expect(performConcurrentRender).not.toThrow()
114+
} else {
115+
// eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests
116+
expect(performConcurrentRender).toThrowError(
117+
`"Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html)."`,
118+
)
119+
}
120+
})
121+
122+
test('can be called multiple times on the same container', () => {
123+
const container = document.createElement('div')
124+
125+
const {unmount} = render(<strong />, {container})
126+
127+
expect(container).toContainHTML('<strong></strong>')
128+
129+
render(<em />, {container})
130+
131+
expect(container).toContainHTML('<em></em>')
132+
133+
unmount()
134+
135+
expect(container).toBeEmptyDOMElement()
136+
})

src/pure.js

+117-36
Original file line numberDiff line numberDiff line change
@@ -19,48 +19,79 @@ configureDTL({
1919
eventWrapper: cb => {
2020
let result
2121
act(() => {
22-
result = cb()
22+
// TODO: Remove ReactDOM.flushSync once `act` flushes the microtask queue.
23+
// Otherwise `act` wrapping updates that schedule microtask would need to be followed with `await null` to flush the microtask queue manually
24+
result = ReactDOM.flushSync(cb)
2325
})
2426
return result
2527
},
2628
})
2729

30+
// Ideally we'd just use a WeakMap where containers are keys and roots are values.
31+
// 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+
/**
33+
* @type {Set<import('react-dom').Container>}
34+
*/
2835
const mountedContainers = new Set()
36+
/**
37+
* @type Array<{container: import('react-dom').Container, root: ReturnType<typeof createConcurrentRoot>}>
38+
*/
39+
const mountedRootEntries = []
2940

30-
function render(
31-
ui,
32-
{
33-
container,
34-
baseElement = container,
35-
queries,
36-
hydrate = false,
37-
wrapper: WrapperComponent,
38-
} = {},
39-
) {
40-
if (!baseElement) {
41-
// default to document.body instead of documentElement to avoid output of potentially-large
42-
// head elements (such as JSS style blocks) in debug output
43-
baseElement = document.body
41+
function createConcurrentRoot(container, options) {
42+
if (typeof ReactDOM.createRoot !== 'function') {
43+
throw new TypeError(
44+
`Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).'`,
45+
)
4446
}
45-
if (!container) {
46-
container = baseElement.appendChild(document.createElement('div'))
47+
const root = ReactDOM.createRoot(container, options)
48+
49+
return {
50+
hydrate(element) {
51+
if (!options.hydrate) {
52+
throw new Error(
53+
'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.',
54+
)
55+
}
56+
root.render(element)
57+
},
58+
render(element) {
59+
root.render(element)
60+
},
61+
unmount() {
62+
root.unmount()
63+
},
4764
}
65+
}
4866

49-
// we'll add it to the mounted containers regardless of whether it's actually
50-
// added to document.body so the cleanup method works regardless of whether
51-
// they're passing us a custom container or not.
52-
mountedContainers.add(container)
67+
function createLegacyRoot(container) {
68+
return {
69+
hydrate(element) {
70+
ReactDOM.hydrate(element, container)
71+
},
72+
render(element) {
73+
ReactDOM.render(element, container)
74+
},
75+
unmount() {
76+
ReactDOM.unmountComponentAtNode(container)
77+
},
78+
}
79+
}
5380

81+
function renderRoot(
82+
ui,
83+
{baseElement, container, hydrate, queries, root, wrapper: WrapperComponent},
84+
) {
5485
const wrapUiIfNeeded = innerElement =>
5586
WrapperComponent
5687
? React.createElement(WrapperComponent, null, innerElement)
5788
: innerElement
5889

5990
act(() => {
6091
if (hydrate) {
61-
ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
92+
root.hydrate(wrapUiIfNeeded(ui), container)
6293
} else {
63-
ReactDOM.render(wrapUiIfNeeded(ui), container)
94+
root.render(wrapUiIfNeeded(ui), container)
6495
}
6596
})
6697

@@ -75,11 +106,15 @@ function render(
75106
console.log(prettyDOM(el, maxLength, options)),
76107
unmount: () => {
77108
act(() => {
78-
ReactDOM.unmountComponentAtNode(container)
109+
root.unmount()
79110
})
80111
},
81112
rerender: rerenderUi => {
82-
render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
113+
renderRoot(wrapUiIfNeeded(rerenderUi), {
114+
container,
115+
baseElement,
116+
root,
117+
})
83118
// Intentionally do not return anything to avoid unnecessarily complicating the API.
84119
// folks can use all the same utilities we return in the first place that are bound to the container
85120
},
@@ -99,20 +134,66 @@ function render(
99134
}
100135
}
101136

102-
function cleanup() {
103-
mountedContainers.forEach(cleanupAtContainer)
137+
function render(
138+
ui,
139+
{
140+
container,
141+
baseElement = container,
142+
concurrent = false,
143+
queries,
144+
hydrate = false,
145+
wrapper,
146+
} = {},
147+
) {
148+
if (!baseElement) {
149+
// default to document.body instead of documentElement to avoid output of potentially-large
150+
// head elements (such as JSS style blocks) in debug output
151+
baseElement = document.body
152+
}
153+
if (!container) {
154+
container = baseElement.appendChild(document.createElement('div'))
155+
}
156+
157+
let root
158+
// 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.
159+
if (!mountedContainers.has(container)) {
160+
const createRoot = concurrent ? createConcurrentRoot : createLegacyRoot
161+
root = createRoot(container, {hydrate})
162+
163+
mountedRootEntries.push({container, root})
164+
// we'll add it to the mounted containers regardless of whether it's actually
165+
// added to document.body so the cleanup method works regardless of whether
166+
// they're passing us a custom container or not.
167+
mountedContainers.add(container)
168+
} else {
169+
mountedRootEntries.forEach(rootEntry => {
170+
if (rootEntry.container === container) {
171+
root = rootEntry.root
172+
}
173+
})
174+
}
175+
176+
return renderRoot(ui, {
177+
container,
178+
baseElement,
179+
queries,
180+
hydrate,
181+
wrapper,
182+
root,
183+
})
104184
}
105185

106-
// maybe one day we'll expose this (perhaps even as a utility returned by render).
107-
// but let's wait until someone asks for it.
108-
function cleanupAtContainer(container) {
109-
act(() => {
110-
ReactDOM.unmountComponentAtNode(container)
186+
function cleanup() {
187+
mountedRootEntries.forEach(({root, container}) => {
188+
act(() => {
189+
root.unmount()
190+
})
191+
if (container.parentNode === document.body) {
192+
document.body.removeChild(container)
193+
}
111194
})
112-
if (container.parentNode === document.body) {
113-
document.body.removeChild(container)
114-
}
115-
mountedContainers.delete(container)
195+
mountedRootEntries.length = 0
196+
mountedContainers.clear()
116197
}
117198

118199
// just re-export everything from dom-testing-library

tests/setup-env.js

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
jest.mock('scheduler', () => require('scheduler/unstable_mock'))
2+
jest.mock('../src/pure')
13
import '@testing-library/jest-dom/extend-expect'

0 commit comments

Comments
 (0)