Skip to content

Commit 58af936

Browse files
committed
setup, no behaviour change
1 parent c9dbfdf commit 58af936

7 files changed

Lines changed: 246 additions & 24 deletions

File tree

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@
9393
"peerDependencies": {
9494
"@jest/globals": "*",
9595
"expect": "*",
96-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc",
97-
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc"
96+
"react": "^18.0.0 || >=19.0.0-rc",
97+
"react-dom": "^18.0.0 || >=19.0.0-rc"
9898
},
9999
"scripts": {
100100
"build": "tsup",

‎src/renderHookToSnapshotStream.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ export interface SnapshotStream<Snapshot, Props> extends Assertable {
1717
/**
1818
* Peeks the next render from the current iterator position, without advancing the iterator.
1919
* If no render has happened yet, it will wait for the next render to happen.
20+
* Resolves after the recorded commit's passive effects have run.
2021
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
2122
*/
2223
peekSnapshot(options?: NextRenderOptions): Promise<Snapshot>
2324
/**
2425
* Iterates to the next render and returns it.
2526
* If no render has happened yet, it will wait for the next render to happen.
27+
* Resolves after the recorded commit's passive effects have run.
2628
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
2729
*/
2830
takeSnapshot: Assertable &
@@ -38,9 +40,11 @@ export interface SnapshotStream<Snapshot, Props> extends Assertable {
3840
getCurrentSnapshot(): Snapshot
3941
/**
4042
* Waits for the next render to happen.
43+
* Resolves after its passive effects have run.
4144
* Does not advance the render iterator.
4245
*/
4346
waitForNextSnapshot(options?: NextRenderOptions): Promise<Snapshot>
47+
/** Rerenders the hook and waits for the commit's passive effects to run. */
4448
rerender: (rerenderCallbackProps: VoidOptionalArg<Props>) => Promise<void>
4549
unmount: () => void
4650
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import {EventEmitter} from 'node:events'
2+
import {describe, expect, test} from '@jest/globals'
3+
import {
4+
createRenderStream,
5+
useTrackRenders,
6+
} from '@testing-library/react-render-stream'
7+
import * as React from 'react'
8+
9+
describe('passive effects', () => {
10+
test('render and rerender wait for subscriptions without consuming renders', async () => {
11+
const events = new EventEmitter()
12+
const received: number[] = []
13+
const stream = createRenderStream<{value: number}>()
14+
function Subscriber({value}: {value: number}) {
15+
stream.replaceSnapshot({value})
16+
React.useEffect(() => {
17+
const listener = () => received.push(value)
18+
events.on('event', listener)
19+
return () => {
20+
events.off('event', listener)
21+
}
22+
}, [value])
23+
return null
24+
}
25+
26+
const {rerender} = await stream.render(<Subscriber value={0} />)
27+
events.emit('event')
28+
await rerender(<Subscriber value={1} />)
29+
events.emit('event')
30+
31+
expect(received).toEqual([0, 1])
32+
expect((await stream.takeRender()).snapshot).toEqual({value: 0})
33+
expect((await stream.takeRender()).snapshot).toEqual({value: 1})
34+
})
35+
36+
test.each(['takeRender', 'peekRender', 'waitForNextRender'] as const)(
37+
'%s waits for effects of descendant-only updates',
38+
async method => {
39+
const events = new EventEmitter()
40+
const received: number[] = []
41+
const stream = createRenderStream<{value: number}>()
42+
let update: React.Dispatch<React.SetStateAction<number>>
43+
let parentRenders = 0
44+
function Subscriber() {
45+
const [value, setValue] = React.useState(0)
46+
update = setValue
47+
stream.replaceSnapshot({value})
48+
React.useEffect(() => {
49+
const listener = () => received.push(value)
50+
events.on('event', listener)
51+
return () => {
52+
events.off('event', listener)
53+
}
54+
}, [value])
55+
return null
56+
}
57+
function Parent() {
58+
parentRenders++
59+
return <Subscriber />
60+
}
61+
62+
await stream.render(<Parent />)
63+
await stream.takeRender()
64+
update!(1)
65+
const render = await stream[method]()
66+
events.emit('event')
67+
68+
expect(render.snapshot).toEqual({value: 1})
69+
expect(received).toEqual([1])
70+
expect(parentRenders).toBe(1)
71+
},
72+
)
73+
74+
test('takeRender waits for effects even when the commit is already recorded', async () => {
75+
let commitRecorded: () => void = () => {}
76+
const stream = createRenderStream<{value: number}>({
77+
onRender: () => commitRecorded(),
78+
})
79+
let update: React.Dispatch<React.SetStateAction<number>>
80+
let effectValue = -1
81+
function Component() {
82+
const [value, setValue] = React.useState(0)
83+
update = setValue
84+
stream.replaceSnapshot({value})
85+
React.useEffect(() => {
86+
effectValue = value
87+
}, [value])
88+
return null
89+
}
90+
91+
await stream.render(<Component />)
92+
await stream.takeRender()
93+
const committed = new Promise<void>(resolve => {
94+
commitRecorded = resolve
95+
})
96+
update!(1)
97+
await committed
98+
expect(stream.renders).toHaveLength(2)
99+
expect((await stream.takeRender()).snapshot).toEqual({value: 1})
100+
expect(effectValue).toBe(1)
101+
})
102+
103+
test('overlapping rerenders both resolve after the batched commit', async () => {
104+
const stream = createRenderStream()
105+
let effectValue = -1
106+
function Component({value}: {value: number}) {
107+
React.useEffect(() => {
108+
effectValue = value
109+
}, [value])
110+
return null
111+
}
112+
const {rerender} = await stream.render(<Component value={0} />)
113+
await Promise.all([
114+
rerender(<Component value={1} />),
115+
rerender(<Component value={2} />),
116+
])
117+
expect(effectValue).toBe(2)
118+
})
119+
120+
test('descendant commits without passive effects still resolve', async () => {
121+
const stream = createRenderStream<{value: number}>()
122+
let update: React.Dispatch<React.SetStateAction<number>>
123+
function Child() {
124+
const [value, setValue] = React.useState(0)
125+
update = setValue
126+
stream.replaceSnapshot({value})
127+
return null
128+
}
129+
await stream.render(<Child />)
130+
await stream.takeRender()
131+
update!(1)
132+
expect((await stream.takeRender()).snapshot).toEqual({value: 1})
133+
})
134+
135+
test('skipped commits do not resolve a waiter for a tracked render', async () => {
136+
const stream = createRenderStream<{value: number}>({
137+
skipNonTrackingRenders: true,
138+
})
139+
let updateTracked: React.Dispatch<React.SetStateAction<number>>,
140+
updateUntracked: React.Dispatch<React.SetStateAction<number>>
141+
let untrackedEffect: () => void = () => {}
142+
function Tracked() {
143+
useTrackRenders()
144+
const [value, setValue] = React.useState(0)
145+
updateTracked = setValue
146+
stream.replaceSnapshot({value})
147+
return null
148+
}
149+
function Untracked() {
150+
const [value, setValue] = React.useState(0)
151+
updateUntracked = setValue
152+
React.useEffect(() => untrackedEffect(), [value])
153+
return null
154+
}
155+
await stream.render(
156+
<>
157+
<Tracked />
158+
<Untracked />
159+
</>,
160+
)
161+
await stream.takeRender()
162+
let resolved = false
163+
const next = stream.takeRender().then(render => {
164+
resolved = true
165+
return render
166+
})
167+
const skipped = new Promise<void>(resolve => {
168+
untrackedEffect = resolve
169+
})
170+
updateUntracked!(1)
171+
await skipped
172+
expect(resolved).toBe(false)
173+
updateTracked!(1)
174+
expect((await next).snapshot).toEqual({value: 1})
175+
})
176+
177+
test('snapshot errors reject pending consumers after effects and preserve iteration', async () => {
178+
const error = new Error('snapshot failed')
179+
const stream = createRenderStream<{value: number}>({
180+
onRender({snapshot}) {
181+
if (snapshot.value === 1) throw error
182+
},
183+
})
184+
let update: React.Dispatch<React.SetStateAction<number>>
185+
let effectValue = -1
186+
function Component() {
187+
const [value, setValue] = React.useState(0)
188+
update = setValue
189+
stream.replaceSnapshot({value})
190+
React.useEffect(() => {
191+
effectValue = value
192+
}, [value])
193+
return null
194+
}
195+
await stream.render(<Component />)
196+
await stream.takeRender()
197+
update!(1)
198+
await expect(stream.takeRender()).rejects.toBe(error)
199+
expect(effectValue).toBe(1)
200+
update!(2)
201+
expect((await stream.takeRender()).snapshot).toEqual({value: 2})
202+
})
203+
204+
test('retains commit-time snapshots across layout and passive effect updates', async () => {
205+
const stream = createRenderStream<{value: number}>({snapshotDOM: true})
206+
const effects: number[] = []
207+
function Component() {
208+
const [value, setValue] = React.useState(0)
209+
stream.replaceSnapshot({value})
210+
React.useLayoutEffect(() => {
211+
if (value === 0) setValue(1)
212+
}, [value])
213+
React.useEffect(() => {
214+
effects.push(value)
215+
if (value === 1) setValue(2)
216+
}, [value])
217+
return <span data-testid="value">{value}</span>
218+
}
219+
await stream.render(<Component />)
220+
for (const value of [0, 1, 2]) {
221+
// eslint-disable-next-line no-await-in-loop
222+
const render = await stream.takeRender()
223+
expect(render.snapshot).toEqual({value})
224+
expect(render.withinDOM().getByTestId('value').textContent).toBe(
225+
String(value),
226+
)
227+
expect(effects).toContain(value)
228+
}
229+
})
230+
})

‎src/renderStream/createRenderStream.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@ export interface RenderStream<
5252
/**
5353
* Peeks the next render from the current iterator position, without advancing the iterator.
5454
* If no render has happened yet, it will wait for the next render to happen.
55+
* Resolves after the recorded commit's passive effects have run.
5556
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
5657
*/
5758
peekRender: (options?: NextRenderOptions) => Promise<Render<Snapshot, Q>>
5859
/**
5960
* Iterates to the next render and returns it.
6061
* If no render has happened yet, it will wait for the next render to happen.
62+
* Resolves after the recorded commit's passive effects have run.
6163
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
6264
*/
6365
takeRender: Assertable &
@@ -73,6 +75,7 @@ export interface RenderStream<
7375
getCurrentRender: () => Render<Snapshot, Q>
7476
/**
7577
* Waits for the next render to happen.
78+
* Resolves after its passive effects have run.
7679
* Does not advance the render iterator.
7780
*/
7881
waitForNextRender: (
@@ -84,6 +87,7 @@ export interface RenderStreamWithRenderFn<
8487
Snapshot extends ValidSnapshot,
8588
Q extends Queries = SyncQueries,
8689
> extends RenderStream<Snapshot, Q> {
90+
/** Renders the UI and waits for the commit's passive effects to run. */
8791
render: RenderWithoutActAsync
8892
}
8993

‎src/renderWithoutAct.tsx‎

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as ReactDOMClient from 'react-dom/client'
2-
import * as ReactDOM from 'react-dom'
32
import {type RenderOptions} from '@testing-library/react/pure.js'
43
import {
54
BoundFunction,
@@ -18,9 +17,9 @@ import {
1817
// Ideally we'd just use a WeakMap where containers are keys and roots are values.
1918
// We use two variables so that we can bail out in constant time when we render with a new container (most common use case)
2019

21-
const mountedContainers: Set<import('react-dom').Container> = new Set()
20+
const mountedContainers: Set<ReactDOMClient.Container> = new Set()
2221
const mountedRootEntries: Array<{
23-
container: import('react-dom').Container
22+
container: ReactDOMClient.Container
2423
root: ReturnType<typeof createConcurrentRoot>
2524
}> = []
2625

@@ -157,11 +156,7 @@ async function _renderWithoutAct(
157156
let root: ReturnType<typeof createConcurrentRoot>
158157
// 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.
159158
if (!mountedContainers.has(container)) {
160-
root = (
161-
ReactDOM.version.startsWith('16') || ReactDOM.version.startsWith('17')
162-
? createLegacyRoot
163-
: createConcurrentRoot
164-
)(container)
159+
root = createConcurrentRoot(container)
165160
mountedRootEntries.push({container, root})
166161
// we'll add it to the mounted containers regardless of whether it's actually
167162
// added to document.body so the cleanup method works regardless of whether
@@ -187,17 +182,6 @@ async function _renderWithoutAct(
187182
})
188183
}
189184

190-
function createLegacyRoot(container: ReactDOMClient.Container) {
191-
return {
192-
render(element: React.ReactNode) {
193-
ReactDOM.render(element as unknown as React.ReactElement, container)
194-
},
195-
unmount() {
196-
ReactDOM.unmountComponentAtNode(container)
197-
},
198-
}
199-
}
200-
201185
function createConcurrentRoot(container: ReactDOMClient.Container) {
202186
const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean}
203187
if (anyThis.IS_REACT_ACT_ENVIRONMENT) {

‎tests/polyfill.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ import {TextEncoder, TextDecoder} from 'util'
33
global.TextEncoder = TextEncoder
44
global.TextDecoder = TextDecoder
55

6-
Symbol.dispose = Symbol('Symbol.dispose')
6+
if (!Symbol.dispose) Symbol.dispose = Symbol('Symbol.dispose')

‎yarn.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2644,8 +2644,8 @@ __metadata:
26442644
peerDependencies:
26452645
"@jest/globals": "*"
26462646
expect: "*"
2647-
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc
2648-
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc
2647+
react: ^18.0.0 || >=19.0.0-rc
2648+
react-dom: ^18.0.0 || >=19.0.0-rc
26492649
languageName: unknown
26502650
linkType: soft
26512651

0 commit comments

Comments
 (0)