Skip to content

Commit d2ef116

Browse files
committed
address review feedback, simplify
1 parent f760018 commit d2ef116

3 files changed

Lines changed: 163 additions & 67 deletions

File tree

‎src/renderStream/__tests__/passiveEffects.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('passive effects', () => {
7373
},
7474
)
7575

76-
test('takeRender waits for effects even when the commit is already recorded', async () => {
76+
test('takeRender waits for effects even when onRender has already run', async () => {
7777
let commitRecorded: () => void = () => {}
7878
const stream = createRenderStream<{value: number}>({
7979
onRender: () => commitRecorded(),
@@ -97,7 +97,6 @@ describe('passive effects', () => {
9797
})
9898
update!(1)
9999
await committed
100-
expect(stream.renders).toHaveLength(2)
101100
expect((await stream.takeRender()).snapshot).toEqual({value: 1})
102101
expect(effectValue).toBe(1)
103102
})
@@ -222,6 +221,7 @@ describe('passive effects', () => {
222221
for (const value of [0, 1, 2]) {
223222
// eslint-disable-next-line no-await-in-loop
224223
const render = await stream.takeRender()
224+
expect(render.count).toBe(value + 1)
225225
expect(render.snapshot).toEqual({value})
226226
expect(render.withinDOM().getByTestId('value').textContent).toBe(
227227
String(value),
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {expect, test} from '@jest/globals'
2+
import {
3+
createRenderStream,
4+
useTrackRenders,
5+
WaitForRenderTimeoutError,
6+
} from '@testing-library/react-render-stream'
7+
import * as React from 'react'
8+
9+
test.each(['render', 'rerender'] as const)(
10+
'%s completes a skipped commit without notifying snapshot consumers',
11+
async method => {
12+
const stream = createRenderStream({skipNonTrackingRenders: true})
13+
let subscribed = false
14+
function Tracked() {
15+
useTrackRenders()
16+
React.useEffect(() => {
17+
subscribed = true
18+
return () => {
19+
subscribed = false
20+
}
21+
}, [])
22+
return <span>tracked</span>
23+
}
24+
const container = document.createElement('div')
25+
const {rerender} = await stream.render(<Tracked />, {container})
26+
await stream.takeRender()
27+
const render = (ui: React.ReactNode) =>
28+
method === 'render' ? stream.render(ui, {container}) : rerender(ui)
29+
let notified = false
30+
const next = stream.takeRender().then(result => {
31+
notified = true
32+
return result
33+
})
34+
35+
await render(null)
36+
expect(container.innerHTML).toBe('')
37+
expect(subscribed).toBe(false)
38+
expect(stream.totalRenderCount()).toBe(1)
39+
expect(notified).toBe(false)
40+
41+
await render(<Tracked />)
42+
expect(subscribed).toBe(true)
43+
expect((await next).count).toBe(2)
44+
},
45+
)
46+
47+
test('initial and overlapping skipped renders finish while snapshot waits still time out', async () => {
48+
const stream = createRenderStream({skipNonTrackingRenders: true})
49+
let effectValue = -1
50+
function Untracked({value}: {value: number}) {
51+
React.useEffect(() => {
52+
effectValue = value
53+
}, [value])
54+
return null
55+
}
56+
const {rerender} = await stream.render(<Untracked value={0} />)
57+
expect(effectValue).toBe(0)
58+
const timeout = expect(
59+
stream.takeRender({timeout: 100}),
60+
).rejects.toBeInstanceOf(WaitForRenderTimeoutError)
61+
await Promise.all([
62+
rerender(<Untracked value={1} />),
63+
rerender(<Untracked value={2} />),
64+
])
65+
expect(effectValue).toBe(2)
66+
expect(stream.totalRenderCount()).toBe(0)
67+
await timeout
68+
})

‎src/renderStream/createRenderStream.tsx‎

Lines changed: 93 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import {type RenderStreamContextValue} from './context.js'
1111
import {RenderStreamContextProvider} from './context.js'
1212
import {syncQueries, type Queries, type SyncQueries} from './syncQueries.js'
1313

14-
let nextProfilerId = 0
15-
1614
export type ValidSnapshot =
1715
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
1816
void | (object & {/* not a function */ call?: never})
@@ -140,19 +138,20 @@ export function createRenderStream<
140138
// created, which is important for the `markAssertable` function
141139
const stream = {} as any as RenderStreamWithRenderFn<Snapshot, Q>
142140

141+
type Commit = PromiseWithResolvers<
142+
(typeof stream.renders)[number] | undefined
143+
> & {
144+
finalize?: () => (typeof stream.renders)[number]
145+
}
143146
let nextRender: Promise<Render<Snapshot, Q>> | undefined,
144-
resolveNextRender: ((render: Render<Snapshot, Q>) => void) | undefined,
145-
rejectNextRender: ((error: unknown) => void) | undefined,
146-
nextRenderTimeout: ReturnType<typeof setTimeout> | undefined
147-
function resetNextRender() {
148-
clearTimeout(nextRenderTimeout)
149-
nextRenderTimeout = undefined
150-
nextRender = undefined
151-
resolveNextRender = undefined
152-
rejectNextRender = undefined
147+
upcomingCommit: Commit | undefined
148+
const pendingCommits: Array<{commitTime: number; commit: Commit}> = []
149+
150+
function nextCommit(): Commit {
151+
return (upcomingCommit ??=
152+
Promise.withResolvers<Awaited<Commit['promise']>>())
153153
}
154-
let effectsFlushedCount = 0
155-
const pendingCommits = new Map<string, number>()
154+
156155
const snapshotRef = {current: initialSnapshot}
157156
const replaceSnapshot: ReplaceSnapshot<Snapshot> = snap => {
158157
if (typeof snap === 'function') {
@@ -193,6 +192,16 @@ export function createRenderStream<
193192
startTime,
194193
commitTime,
195194
) => {
195+
const commit = nextCommit()
196+
upcomingCommit = undefined
197+
pendingCommits.push({commitTime, commit})
198+
if (/^(16|17)\./.test(React.version)) {
199+
// React 16/17 have no onPostCommit hook. Let the commit finish scheduling
200+
// passive effects before yielding a task for them to run.
201+
void Promise.resolve().then(() => {
202+
setTimeout(() => profilerOnPostCommit(id, phase, 0, commitTime), 0)
203+
})
204+
}
196205
if (
197206
skipNonTrackingRenders &&
198207
renderStreamContext.renderedComponents.length === 0
@@ -207,7 +216,10 @@ export function createRenderStream<
207216
baseDuration,
208217
startTime,
209218
commitTime,
210-
count: stream.renders.length + 1,
219+
count:
220+
stream.renders.length +
221+
pendingCommits.filter(pending => pending.commit.finalize).length +
222+
1,
211223
}
212224
try {
213225
/*
@@ -236,51 +248,43 @@ export function createRenderStream<
236248
queries,
237249
)
238250
renderStreamContext.renderedComponents = []
239-
stream.renders.push(render)
251+
commit.finalize = () => {
252+
stream.renders.push(render)
253+
return render
254+
}
240255
} catch (error) {
241-
stream.renders.push({
242-
phase: 'snapshotError',
243-
count: stream.renders.length,
244-
error,
245-
})
246-
} finally {
247-
pendingCommits.set(`${id}:${commitTime}`, stream.renders.length)
248-
if (/^(16|17)\./.test(React.version)) {
249-
// React 16/17 have no onPostCommit hook. Let the commit finish scheduling
250-
// passive effects before yielding a task for them to run.
251-
void Promise.resolve().then(() => {
252-
setTimeout(() => profilerOnPostCommit(id, phase, 0, commitTime), 0)
253-
})
256+
commit.finalize = () => {
257+
const err = {
258+
phase: 'snapshotError' as const,
259+
count: stream.renders.length,
260+
error,
261+
}
262+
stream.renders.push(err)
263+
return err
254264
}
255265
}
256266
}
257267

258268
function profilerOnPostCommit(
259-
id: string,
269+
_id: string,
260270
_phase: string,
261271
_passiveEffectDuration: number,
262272
commitTime: number,
263273
) {
264-
const key = `${id}:${commitTime}`
265-
const renderCount = pendingCommits.get(key)
266-
if (renderCount === undefined) return
267-
pendingCommits.delete(key)
268-
const render = stream.renders[effectsFlushedCount]
269-
effectsFlushedCount = renderCount
270-
if (render.phase === 'snapshotError') {
271-
rejectNextRender?.(render.error)
272-
} else {
273-
resolveNextRender?.(render)
274-
}
275-
resetNextRender()
274+
const index = pendingCommits.findIndex(
275+
pending => pending.commitTime === commitTime,
276+
)
277+
if (index === -1) return
278+
const {commit} = pendingCommits.splice(index, 1)[0]
279+
commit.resolve(commit.finalize?.())
276280
}
277281

278282
let iteratorPosition = 0
279-
function Wrapper({children, id}: {children: React.ReactNode; id: string}) {
283+
function Wrapper({children}: {children: React.ReactNode}) {
280284
return (
281285
<RenderStreamContextProvider value={renderStreamContext}>
282286
<React.Profiler
283-
id={id}
287+
id="test"
284288
onRender={profilerOnRender}
285289
// Supported in React 18+ development/profiling builds, but untyped.
286290
{...{onPostCommit: profilerOnPostCommit}}
@@ -295,33 +299,24 @@ export function createRenderStream<
295299
ui: React.ReactNode,
296300
options?: RenderOptions<any, any, any>,
297301
) => {
298-
const id = `render-stream-${nextProfilerId++}`
299-
const previousRenderCount = stream.renders.length
302+
const commit = nextCommit()
300303
const ret = await renderWithoutAct(ui, {
301304
...options,
302305
wrapper: props => {
303306
const ParentWrapper = options?.wrapper ?? React.Fragment
304307
return (
305308
<ParentWrapper>
306-
<Wrapper id={id}>{props.children}</Wrapper>
309+
<Wrapper>{props.children}</Wrapper>
307310
</ParentWrapper>
308311
)
309312
},
310313
})
311-
// eslint-disable-next-line no-unmodified-loop-condition -- updated by onPostCommit
312-
while (effectsFlushedCount <= previousRenderCount) {
313-
// eslint-disable-next-line no-await-in-loop
314-
await stream.waitForNextRender()
315-
}
314+
await waitForCommit(commit)
316315
const origRerender = ret.rerender
317316
ret.rerender = async function rerender(rerenderUi: React.ReactNode) {
318-
const rerenderPosition = stream.renders.length
317+
const rerenderCommit = nextCommit()
319318
await origRerender(rerenderUi)
320-
// eslint-disable-next-line no-unmodified-loop-condition -- updated by onPostCommit
321-
while (effectsFlushedCount <= rerenderPosition) {
322-
// eslint-disable-next-line no-await-in-loop
323-
await stream.waitForNextRender()
324-
}
319+
await waitForCommit(rerenderCommit)
325320
}
326321
return ret
327322
}) as unknown as RenderWithoutActAsync // TODO
@@ -337,7 +332,7 @@ export function createRenderStream<
337332
return stream.renders.length
338333
},
339334
async peekRender(options: NextRenderOptions = {}) {
340-
if (iteratorPosition < effectsFlushedCount) {
335+
if (iteratorPosition < stream.renders.length) {
341336
const peekedRender = stream.renders[iteratorPosition]
342337

343338
if (peekedRender.phase === 'snapshotError') {
@@ -394,26 +389,59 @@ export function createRenderStream<
394389
},
395390
waitForNextRender({timeout = 1000}: NextRenderOptions = {}) {
396391
if (!nextRender) {
392+
const position = stream.renders.length
393+
const wait = {active: true}
394+
let timer: ReturnType<typeof setTimeout> | undefined
397395
nextRender = Promise.race<Render<Snapshot, Q>>([
398-
new Promise<Render<Snapshot, Q>>((resolve, reject) => {
399-
resolveNextRender = resolve
400-
rejectNextRender = reject
401-
}),
396+
(async () => {
397+
while (wait.active && stream.renders.length === position) {
398+
// A skipped commit completes without publishing a snapshot.
399+
// eslint-disable-next-line no-await-in-loop
400+
await (pendingCommits[0]?.commit ?? nextCommit()).promise
401+
}
402+
if (!wait.active) throw new WaitForRenderTimeoutError()
403+
const next = stream.renders[position]
404+
if (next.phase === 'snapshotError') throw next.error
405+
return next
406+
})(),
402407
new Promise<Render<Snapshot, Q>>((_, reject) => {
403-
nextRenderTimeout = setTimeout(() => {
408+
timer = setTimeout(() => {
404409
const error = new WaitForRenderTimeoutError()
405410
Error.captureStackTrace(error, stream.waitForNextRender)
406411
reject(error)
407-
resetNextRender()
408412
}, timeout)
409413
}),
410-
])
414+
]).finally(() => {
415+
wait.active = false
416+
clearTimeout(timer)
417+
nextRender = undefined
418+
})
411419
}
412420
return nextRender
413421
},
414422
render,
415423
})
416424
return stream
425+
426+
async function waitForCommit(commit: Commit) {
427+
let timer: ReturnType<typeof setTimeout> | undefined
428+
try {
429+
return await Promise.race([
430+
commit.promise.then(result => {
431+
if (result?.phase === 'snapshotError') throw result.error
432+
return result
433+
}),
434+
new Promise<never>((_, reject) => {
435+
timer = setTimeout(
436+
() => reject(new WaitForRenderTimeoutError()),
437+
1000,
438+
)
439+
}),
440+
])
441+
} finally {
442+
clearTimeout(timer)
443+
}
444+
}
417445
}
418446

419447
function rethrowWithCapturedStackTrace(constructorOpt: Function | undefined) {

0 commit comments

Comments
 (0)