Skip to content

Commit a530401

Browse files
authored
feat: Use onPostCommit to ensure that render/rerender and takeNext promises resolve after effects are run (#18)
As reported in #17, since React 19.1, passive effects (useEffect, and the subscription in useSyncExternalStore) can run in a later task than their commit. That means that with the current approach, `render/rerender` or even `takeNext` could trigger before all effects for that render had finished processing. This switches over to `onPostCommit`, an api that is not part of the official React types, but has been around stable since React 18, based on [this React RFC](https://github.com/reactjs/rfcs/pull/139/changes) and implemented in react/react#17910. React 16.8+ and 17 retain existing behaviour.
1 parent c9dbfdf commit a530401

10 files changed

Lines changed: 507 additions & 67 deletions

‎.github/workflows/ci.yml‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ on:
1010

1111
jobs:
1212
ci:
13-
name: CI
13+
name: CI (React ${{ matrix.react }})
1414
runs-on: ubuntu-latest
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
react: ['16.x', '17.x', '18.x', '19.0', '19.x']
1519
steps:
1620
- name: Checkout repo
1721
uses: actions/checkout@v4
@@ -25,6 +29,15 @@ jobs:
2529
- name: Install dependencies
2630
run: yarn install
2731

32+
- name: Install React ${{ matrix.react }}
33+
run:
34+
yarn add --dev react@${{ matrix.react }} react-dom@${{ matrix.react }}
35+
env:
36+
YARN_ENABLE_IMMUTABLE_INSTALLS: false
37+
38+
- name: Build package
39+
run: yarn build
40+
2841
- name: Run tests, lint and verify package integrity
2942
run: yarn run validate
3043

‎jest.config.cjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,11 @@ const {jest: jestConfig} = require('kcd-scripts/config')
33
module.exports = Object.assign(jestConfig, {
44
resolver: 'ts-jest-resolver',
55
prettierPath: null,
6+
// React 16/17 use the legacy renderer and never call the client APIs.
7+
...(/^(16|17)\./.test(require('react').version) && {
8+
moduleNameMapper: {
9+
...jestConfig.moduleNameMapper,
10+
'^react-dom/client$': require.resolve('react-dom'),
11+
},
12+
}),
613
})

‎src/__tests__/renderHookToSnapshotStream.test.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
} from '@testing-library/react-render-stream'
1010
import * as React from 'react'
1111

12+
const testWithExternalStore =
13+
typeof React.useSyncExternalStore === 'function' ? test : test.skip
14+
1215
const testEvents = new EventEmitter<{
1316
rerenderWithValue: [unknown]
1417
}>()
@@ -32,7 +35,7 @@ function useRerenderEvents(initialValue: unknown) {
3235
)
3336
}
3437

35-
test('basic functionality', async () => {
38+
testWithExternalStore('basic functionality', async () => {
3639
const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, {
3740
initialProps: 'initial',
3841
})
@@ -53,7 +56,9 @@ test('basic functionality', async () => {
5356
}
5457
})
5558

56-
test.each<[type: string, initialValue: unknown, ...nextValues: unknown[]]>([
59+
testWithExternalStore.each<
60+
[type: string, initialValue: unknown, ...nextValues: unknown[]]
61+
>([
5762
['string', 'initial', 'value', 'value2'],
5863
['number', 0, 1, 2],
5964
['functions', () => {}, () => {}, function named() {}],

‎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: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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 parentCommits = 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+
React.useLayoutEffect(() => {
59+
parentCommits++
60+
})
61+
return <Subscriber />
62+
}
63+
64+
await stream.render(<Parent />)
65+
await stream.takeRender()
66+
update!(1)
67+
const render = await stream[method]()
68+
events.emit('event')
69+
70+
expect(render.snapshot).toEqual({value: 1})
71+
expect(received).toEqual([1])
72+
expect(parentCommits).toBe(1)
73+
},
74+
)
75+
76+
test('takeRender waits for effects even when onRender has already run', async () => {
77+
let commitRecorded: () => void = () => {}
78+
const stream = createRenderStream<{value: number}>({
79+
onRender: () => commitRecorded(),
80+
})
81+
let update: React.Dispatch<React.SetStateAction<number>>
82+
let effectValue = -1
83+
function Component() {
84+
const [value, setValue] = React.useState(0)
85+
update = setValue
86+
stream.replaceSnapshot({value})
87+
React.useEffect(() => {
88+
effectValue = value
89+
}, [value])
90+
return null
91+
}
92+
93+
await stream.render(<Component />)
94+
await stream.takeRender()
95+
const committed = new Promise<void>(resolve => {
96+
commitRecorded = resolve
97+
})
98+
update!(1)
99+
await committed
100+
expect((await stream.takeRender()).snapshot).toEqual({value: 1})
101+
expect(effectValue).toBe(1)
102+
})
103+
104+
test('overlapping rerenders both resolve after the batched commit', async () => {
105+
const stream = createRenderStream()
106+
let effectValue = -1
107+
function Component({value}: {value: number}) {
108+
React.useEffect(() => {
109+
effectValue = value
110+
}, [value])
111+
return null
112+
}
113+
const {rerender} = await stream.render(<Component value={0} />)
114+
await Promise.all([
115+
rerender(<Component value={1} />),
116+
rerender(<Component value={2} />),
117+
])
118+
expect(effectValue).toBe(2)
119+
})
120+
121+
test('descendant commits without passive effects still resolve', async () => {
122+
const stream = createRenderStream<{value: number}>()
123+
let update: React.Dispatch<React.SetStateAction<number>>
124+
function Child() {
125+
const [value, setValue] = React.useState(0)
126+
update = setValue
127+
stream.replaceSnapshot({value})
128+
return null
129+
}
130+
await stream.render(<Child />)
131+
await stream.takeRender()
132+
update!(1)
133+
expect((await stream.takeRender()).snapshot).toEqual({value: 1})
134+
})
135+
136+
test('skipped commits do not resolve a waiter for a tracked render', async () => {
137+
const stream = createRenderStream<{value: number}>({
138+
skipNonTrackingRenders: true,
139+
})
140+
let updateTracked: React.Dispatch<React.SetStateAction<number>>,
141+
updateUntracked: React.Dispatch<React.SetStateAction<number>>
142+
let untrackedEffect: () => void = () => {}
143+
function Tracked() {
144+
useTrackRenders()
145+
const [value, setValue] = React.useState(0)
146+
updateTracked = setValue
147+
stream.replaceSnapshot({value})
148+
return null
149+
}
150+
function Untracked() {
151+
const [value, setValue] = React.useState(0)
152+
updateUntracked = setValue
153+
React.useEffect(() => untrackedEffect(), [value])
154+
return null
155+
}
156+
await stream.render(
157+
<>
158+
<Tracked />
159+
<Untracked />
160+
</>,
161+
)
162+
await stream.takeRender()
163+
let resolved = false
164+
const next = stream.takeRender().then(render => {
165+
resolved = true
166+
return render
167+
})
168+
const skipped = new Promise<void>(resolve => {
169+
untrackedEffect = resolve
170+
})
171+
updateUntracked!(1)
172+
await skipped
173+
expect(resolved).toBe(false)
174+
updateTracked!(1)
175+
expect((await next).snapshot).toEqual({value: 1})
176+
})
177+
178+
test('snapshot errors reject pending consumers after effects and preserve iteration', async () => {
179+
const error = new Error('snapshot failed')
180+
const stream = createRenderStream<{value: number}>({
181+
onRender({snapshot}) {
182+
if (snapshot.value === 1) throw error
183+
},
184+
})
185+
let update: React.Dispatch<React.SetStateAction<number>>
186+
let effectValue = -1
187+
function Component() {
188+
const [value, setValue] = React.useState(0)
189+
update = setValue
190+
stream.replaceSnapshot({value})
191+
React.useEffect(() => {
192+
effectValue = value
193+
}, [value])
194+
return null
195+
}
196+
await stream.render(<Component />)
197+
await stream.takeRender()
198+
update!(1)
199+
await expect(stream.takeRender()).rejects.toBe(error)
200+
expect(effectValue).toBe(1)
201+
update!(2)
202+
expect((await stream.takeRender()).snapshot).toEqual({value: 2})
203+
})
204+
205+
test('retains commit-time snapshots across layout and passive effect updates', async () => {
206+
const stream = createRenderStream<{value: number}>({snapshotDOM: true})
207+
const effects: number[] = []
208+
function Component() {
209+
const [value, setValue] = React.useState(0)
210+
stream.replaceSnapshot({value})
211+
React.useLayoutEffect(() => {
212+
if (value === 0) setValue(1)
213+
}, [value])
214+
React.useEffect(() => {
215+
effects.push(value)
216+
if (value === 1) setValue(2)
217+
}, [value])
218+
return <span data-testid="value">{value}</span>
219+
}
220+
await stream.render(<Component />)
221+
for (const value of [0, 1, 2]) {
222+
// eslint-disable-next-line no-await-in-loop
223+
const render = await stream.takeRender()
224+
expect(render.count).toBe(value + 1)
225+
expect(render.snapshot).toEqual({value})
226+
expect(render.withinDOM().getByTestId('value').textContent).toBe(
227+
String(value),
228+
)
229+
expect(effects).toContain(value)
230+
}
231+
})
232+
})

0 commit comments

Comments
 (0)