@tanstack/react-start-client 1.167.3 → 1.168.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/GenericHydrate.d.ts +3 -0
- package/dist/esm/GenericHydrate.js +243 -0
- package/dist/esm/GenericHydrate.js.map +1 -0
- package/dist/esm/Hydrate.d.ts +31 -0
- package/dist/esm/Hydrate.js +34 -0
- package/dist/esm/Hydrate.js.map +1 -0
- package/dist/esm/hydration/generic.d.ts +7 -0
- package/dist/esm/hydration/generic.js +20 -0
- package/dist/esm/hydration/generic.js.map +1 -0
- package/dist/esm/hydration/idle.d.ts +3 -0
- package/dist/esm/hydration/idle.js +12 -0
- package/dist/esm/hydration/idle.js.map +1 -0
- package/dist/esm/hydration/load.d.ts +5 -0
- package/dist/esm/hydration/load.js +33 -0
- package/dist/esm/hydration/load.js.map +1 -0
- package/dist/esm/hydration/never.d.ts +4 -0
- package/dist/esm/hydration/never.js +56 -0
- package/dist/esm/hydration/never.js.map +1 -0
- package/dist/esm/hydration/visible.d.ts +5 -0
- package/dist/esm/hydration/visible.js +94 -0
- package/dist/esm/hydration/visible.js.map +1 -0
- package/dist/esm/hydration.d.ts +7 -0
- package/dist/esm/hydration.js +7 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +3 -1
- package/dist/esm/tests/Hydrate.test-d.d.ts +1 -0
- package/dist/esm/tests/Hydrate.test.d.ts +1 -0
- package/package.json +10 -4
- package/src/GenericHydrate.tsx +436 -0
- package/src/Hydrate.tsx +107 -0
- package/src/hydration/generic.ts +43 -0
- package/src/hydration/idle.ts +22 -0
- package/src/hydration/load.tsx +49 -0
- package/src/hydration/never.tsx +97 -0
- package/src/hydration/visible.tsx +139 -0
- package/src/hydration.ts +22 -0
- package/src/index.tsx +16 -0
- package/src/tests/Hydrate.test-d.tsx +147 -0
- package/src/tests/Hydrate.test.tsx +676 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { hydrateRoot } from 'react-dom/client'
|
|
4
|
+
import {
|
|
5
|
+
act,
|
|
6
|
+
cleanup,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from '@testing-library/react'
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { hydrateIdAttribute } from '@tanstack/start-client-core/hydration/constants'
|
|
14
|
+
import { Hydrate } from '../Hydrate'
|
|
15
|
+
import { condition, idle, interaction, load, never } from '../hydration'
|
|
16
|
+
import type { HydrateProps, HydrationPrefetchStrategy } from '../Hydrate'
|
|
17
|
+
|
|
18
|
+
const InternalHydrate = Hydrate as React.ComponentType<
|
|
19
|
+
HydrateProps & { p?: () => Promise<void>; h?: string }
|
|
20
|
+
>
|
|
21
|
+
|
|
22
|
+
const hydrateIdSelector = `[${hydrateIdAttribute}]`
|
|
23
|
+
|
|
24
|
+
function getMarker() {
|
|
25
|
+
const marker = document.querySelector(hydrateIdSelector)
|
|
26
|
+
|
|
27
|
+
if (!marker) {
|
|
28
|
+
throw new Error('Expected Hydrate marker to exist')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return marker
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function InteractiveChild() {
|
|
35
|
+
const [count, setCount] = React.useState(0)
|
|
36
|
+
const [hydrated, setHydrated] = React.useState(false)
|
|
37
|
+
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
setHydrated(true)
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
data-testid="child"
|
|
45
|
+
data-hydrated={hydrated ? 'true' : 'false'}
|
|
46
|
+
onClick={() => setCount((prev) => prev + 1)}
|
|
47
|
+
>
|
|
48
|
+
{count}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function NamedInteractiveChild(props: { id: string }) {
|
|
54
|
+
const [hydrated, setHydrated] = React.useState(false)
|
|
55
|
+
|
|
56
|
+
React.useEffect(() => {
|
|
57
|
+
setHydrated(true)
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
data-testid={`child-${props.id}`}
|
|
63
|
+
data-hydrated={hydrated ? 'true' : 'false'}
|
|
64
|
+
>
|
|
65
|
+
{props.id}
|
|
66
|
+
</button>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createSuspendingChild() {
|
|
71
|
+
let resolve!: () => void
|
|
72
|
+
let resolved = false
|
|
73
|
+
const promise = new Promise<void>((resolvePromise) => {
|
|
74
|
+
resolve = () => {
|
|
75
|
+
resolved = true
|
|
76
|
+
resolvePromise()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
function SuspendingChild() {
|
|
81
|
+
if (!resolved) {
|
|
82
|
+
throw promise
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return <div data-testid="child">child</div>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { resolve, SuspendingChild }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function expectNoHydrationAfterDefaultIntentEvents() {
|
|
92
|
+
const marker = getMarker()
|
|
93
|
+
|
|
94
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
95
|
+
'false',
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
fireEvent.pointerEnter(marker)
|
|
100
|
+
fireEvent.focusIn(marker)
|
|
101
|
+
fireEvent.pointerDown(marker)
|
|
102
|
+
fireEvent.click(marker)
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
107
|
+
'false',
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fireIntent(event: () => void) {
|
|
112
|
+
await act(async () => {
|
|
113
|
+
event()
|
|
114
|
+
await Promise.resolve()
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function renderAsync(ui: React.ReactElement) {
|
|
119
|
+
await act(async () => {
|
|
120
|
+
render(ui)
|
|
121
|
+
await Promise.resolve()
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function hydrateFromServer(ui: React.ReactElement) {
|
|
126
|
+
vi.stubGlobal('window', undefined)
|
|
127
|
+
const html = renderToString(ui)
|
|
128
|
+
vi.unstubAllGlobals()
|
|
129
|
+
|
|
130
|
+
const container = document.createElement('div')
|
|
131
|
+
document.body.append(container)
|
|
132
|
+
container.innerHTML = html
|
|
133
|
+
|
|
134
|
+
let root!: ReturnType<typeof hydrateRoot>
|
|
135
|
+
await act(async () => {
|
|
136
|
+
root = hydrateRoot(container, ui)
|
|
137
|
+
await Promise.resolve()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return { container, html, root }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function unmountHydratedRoot(
|
|
144
|
+
root: ReturnType<typeof hydrateRoot>,
|
|
145
|
+
container: Element,
|
|
146
|
+
) {
|
|
147
|
+
await act(async () => {
|
|
148
|
+
root.unmount()
|
|
149
|
+
})
|
|
150
|
+
container.remove()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
cleanup()
|
|
155
|
+
vi.unstubAllGlobals()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('Hydrate', () => {
|
|
159
|
+
it('uses a single custom interaction event instead of the default intent events', async () => {
|
|
160
|
+
const { container, html, root } = await hydrateFromServer(
|
|
161
|
+
<Hydrate
|
|
162
|
+
when={interaction({ events: 'dblclick' })}
|
|
163
|
+
fallback={<div data-testid="fallback">fallback</div>}
|
|
164
|
+
>
|
|
165
|
+
<InteractiveChild />
|
|
166
|
+
</Hydrate>,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
expect(html).toContain('data-testid="child"')
|
|
171
|
+
expect(html).not.toContain('data-testid="fallback"')
|
|
172
|
+
expect(screen.queryByTestId('fallback')).toBeNull()
|
|
173
|
+
await expectNoHydrationAfterDefaultIntentEvents()
|
|
174
|
+
|
|
175
|
+
await fireIntent(() =>
|
|
176
|
+
getMarker().dispatchEvent(
|
|
177
|
+
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
await waitFor(() =>
|
|
182
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
183
|
+
'true',
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
} finally {
|
|
187
|
+
await unmountHydratedRoot(root, container)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('uses every event in a custom interaction event list', async () => {
|
|
192
|
+
const { container, root } = await hydrateFromServer(
|
|
193
|
+
<Hydrate
|
|
194
|
+
when={interaction({ events: ['contextmenu', 'dblclick'] })}
|
|
195
|
+
fallback={<div data-testid="fallback">fallback</div>}
|
|
196
|
+
>
|
|
197
|
+
<InteractiveChild />
|
|
198
|
+
</Hydrate>,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
expect(screen.queryByTestId('fallback')).toBeNull()
|
|
203
|
+
await expectNoHydrationAfterDefaultIntentEvents()
|
|
204
|
+
|
|
205
|
+
await fireIntent(() =>
|
|
206
|
+
getMarker().dispatchEvent(
|
|
207
|
+
new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
await waitFor(() =>
|
|
212
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
213
|
+
'true',
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
} finally {
|
|
217
|
+
await unmountHydratedRoot(root, container)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('omits never content when mounted after the app is already hydrated', async () => {
|
|
222
|
+
await renderAsync(
|
|
223
|
+
<Hydrate when={never()}>
|
|
224
|
+
<InteractiveChild />
|
|
225
|
+
</Hydrate>,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(screen.queryByTestId('child')).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('shows fallback for a client-only mount while children suspend', async () => {
|
|
232
|
+
const { resolve, SuspendingChild } = createSuspendingChild()
|
|
233
|
+
|
|
234
|
+
await renderAsync(
|
|
235
|
+
<Hydrate
|
|
236
|
+
when={load()}
|
|
237
|
+
fallback={<div data-testid="fallback">fallback</div>}
|
|
238
|
+
>
|
|
239
|
+
<SuspendingChild />
|
|
240
|
+
</Hydrate>,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
expect(screen.getByTestId('fallback').textContent).toBe('fallback')
|
|
244
|
+
expect(screen.queryByTestId('child')).toBeNull()
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
resolve()
|
|
248
|
+
await Promise.resolve()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
await screen.findByTestId('child')
|
|
252
|
+
expect(screen.queryByTestId('fallback')).toBeNull()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('does not use fallback for an initial never boundary', async () => {
|
|
256
|
+
const { container, html, root } = await hydrateFromServer(
|
|
257
|
+
<Hydrate
|
|
258
|
+
when={never()}
|
|
259
|
+
fallback={<div data-testid="fallback">fallback</div>}
|
|
260
|
+
>
|
|
261
|
+
<InteractiveChild />
|
|
262
|
+
</Hydrate>,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
expect(html).toContain('data-testid="child"')
|
|
267
|
+
expect(html).not.toContain('data-testid="fallback"')
|
|
268
|
+
expect(screen.queryByTestId('fallback')).toBeNull()
|
|
269
|
+
|
|
270
|
+
fireEvent.click(screen.getByTestId('child'))
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
272
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
273
|
+
'false',
|
|
274
|
+
)
|
|
275
|
+
expect(screen.getByTestId('child').textContent).toBe('0')
|
|
276
|
+
} finally {
|
|
277
|
+
await unmountHydratedRoot(root, container)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('keeps repeated split boundaries independently gated', async () => {
|
|
282
|
+
const { container, root } = await hydrateFromServer(
|
|
283
|
+
<>
|
|
284
|
+
<InternalHydrate when={interaction()} h="shared-boundary">
|
|
285
|
+
<NamedInteractiveChild id="one" />
|
|
286
|
+
</InternalHydrate>
|
|
287
|
+
<InternalHydrate when={interaction()} h="shared-boundary">
|
|
288
|
+
<NamedInteractiveChild id="two" />
|
|
289
|
+
</InternalHydrate>
|
|
290
|
+
</>,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const markers = container.querySelectorAll(hydrateIdSelector)
|
|
295
|
+
|
|
296
|
+
expect(markers).toHaveLength(2)
|
|
297
|
+
expect(markers[0]!.getAttribute(hydrateIdAttribute)).not.toBe(
|
|
298
|
+
markers[1]!.getAttribute(hydrateIdAttribute),
|
|
299
|
+
)
|
|
300
|
+
expect(
|
|
301
|
+
screen.getByTestId('child-one').getAttribute('data-hydrated'),
|
|
302
|
+
).toBe('false')
|
|
303
|
+
expect(
|
|
304
|
+
screen.getByTestId('child-two').getAttribute('data-hydrated'),
|
|
305
|
+
).toBe('false')
|
|
306
|
+
|
|
307
|
+
await fireIntent(() =>
|
|
308
|
+
markers[0]!.dispatchEvent(
|
|
309
|
+
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
await waitFor(() =>
|
|
314
|
+
expect(
|
|
315
|
+
screen.getByTestId('child-one').getAttribute('data-hydrated'),
|
|
316
|
+
).toBe('true'),
|
|
317
|
+
)
|
|
318
|
+
expect(
|
|
319
|
+
screen.getByTestId('child-two').getAttribute('data-hydrated'),
|
|
320
|
+
).toBe('false')
|
|
321
|
+
} finally {
|
|
322
|
+
await unmountHydratedRoot(root, container)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('fires onHydrated once after the client hydration commit', async () => {
|
|
327
|
+
const onHydrated = vi.fn()
|
|
328
|
+
const app = (
|
|
329
|
+
<Hydrate when={load()} onHydrated={onHydrated}>
|
|
330
|
+
<div data-testid="child">child</div>
|
|
331
|
+
</Hydrate>
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
vi.stubGlobal('window', undefined)
|
|
335
|
+
const html = renderToString(app)
|
|
336
|
+
expect(html).toContain('child')
|
|
337
|
+
expect(onHydrated).not.toHaveBeenCalled()
|
|
338
|
+
vi.unstubAllGlobals()
|
|
339
|
+
|
|
340
|
+
const container = document.createElement('div')
|
|
341
|
+
document.body.append(container)
|
|
342
|
+
container.innerHTML = html
|
|
343
|
+
|
|
344
|
+
let root!: ReturnType<typeof hydrateRoot>
|
|
345
|
+
await act(async () => {
|
|
346
|
+
root = hydrateRoot(container, app)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
await waitFor(() => expect(onHydrated).toHaveBeenCalledTimes(1))
|
|
350
|
+
|
|
351
|
+
fireEvent.click(screen.getByTestId('child'))
|
|
352
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
353
|
+
expect(onHydrated).toHaveBeenCalledTimes(1)
|
|
354
|
+
|
|
355
|
+
await act(async () => {
|
|
356
|
+
root.unmount()
|
|
357
|
+
})
|
|
358
|
+
container.remove()
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('prefetches split children without hydrating the boundary', async () => {
|
|
362
|
+
const preload = vi.fn(() => Promise.resolve())
|
|
363
|
+
|
|
364
|
+
const { container, root } = await hydrateFromServer(
|
|
365
|
+
<InternalHydrate
|
|
366
|
+
when={interaction()}
|
|
367
|
+
prefetch={idle({ timeout: 1 })}
|
|
368
|
+
p={preload}
|
|
369
|
+
>
|
|
370
|
+
<InteractiveChild />
|
|
371
|
+
</InternalHydrate>,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await waitFor(() => expect(preload).toHaveBeenCalledTimes(1))
|
|
376
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
377
|
+
'false',
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
await fireIntent(() =>
|
|
381
|
+
getMarker().dispatchEvent(
|
|
382
|
+
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
await waitFor(() =>
|
|
387
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
388
|
+
'true',
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
expect(preload).toHaveBeenCalledTimes(1)
|
|
392
|
+
} finally {
|
|
393
|
+
await unmountHydratedRoot(root, container)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('does not evaluate dynamic when callbacks on the server', async () => {
|
|
398
|
+
const when = vi.fn(() => interaction({ events: 'dblclick' }))
|
|
399
|
+
|
|
400
|
+
vi.stubGlobal('window', undefined)
|
|
401
|
+
const html = renderToString(
|
|
402
|
+
<Hydrate when={when}>
|
|
403
|
+
<InteractiveChild />
|
|
404
|
+
</Hydrate>,
|
|
405
|
+
)
|
|
406
|
+
vi.unstubAllGlobals()
|
|
407
|
+
|
|
408
|
+
expect(when).not.toHaveBeenCalled()
|
|
409
|
+
expect(html).toContain('data-ts-hydrate-when="dynamic"')
|
|
410
|
+
|
|
411
|
+
const container = document.createElement('div')
|
|
412
|
+
document.body.append(container)
|
|
413
|
+
container.innerHTML = html
|
|
414
|
+
|
|
415
|
+
let root!: ReturnType<typeof hydrateRoot>
|
|
416
|
+
try {
|
|
417
|
+
await act(async () => {
|
|
418
|
+
root = hydrateRoot(
|
|
419
|
+
container,
|
|
420
|
+
<Hydrate when={when}>
|
|
421
|
+
<InteractiveChild />
|
|
422
|
+
</Hydrate>,
|
|
423
|
+
)
|
|
424
|
+
await Promise.resolve()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
expect(when).toHaveBeenCalled()
|
|
428
|
+
await expectNoHydrationAfterDefaultIntentEvents()
|
|
429
|
+
|
|
430
|
+
await fireIntent(() =>
|
|
431
|
+
getMarker().dispatchEvent(
|
|
432
|
+
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
|
|
433
|
+
),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
await waitFor(() =>
|
|
437
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
438
|
+
'true',
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
} finally {
|
|
442
|
+
await unmountHydratedRoot(root, container)
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('replays an interaction captured before the Hydrate component hydrates', async () => {
|
|
447
|
+
const when = () => interaction({ events: 'click' })
|
|
448
|
+
|
|
449
|
+
vi.stubGlobal('window', undefined)
|
|
450
|
+
const html = renderToString(
|
|
451
|
+
<Hydrate when={when}>
|
|
452
|
+
<InteractiveChild />
|
|
453
|
+
</Hydrate>,
|
|
454
|
+
)
|
|
455
|
+
vi.unstubAllGlobals()
|
|
456
|
+
|
|
457
|
+
const container = document.createElement('div')
|
|
458
|
+
document.body.append(container)
|
|
459
|
+
container.innerHTML = html
|
|
460
|
+
|
|
461
|
+
const button = container.querySelector('[data-testid="child"]')
|
|
462
|
+
if (!button) {
|
|
463
|
+
throw new Error('Expected server-rendered child button')
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
button.dispatchEvent(
|
|
467
|
+
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
let root!: ReturnType<typeof hydrateRoot>
|
|
471
|
+
try {
|
|
472
|
+
await act(async () => {
|
|
473
|
+
root = hydrateRoot(
|
|
474
|
+
container,
|
|
475
|
+
<Hydrate when={when}>
|
|
476
|
+
<InteractiveChild />
|
|
477
|
+
</Hydrate>,
|
|
478
|
+
)
|
|
479
|
+
await Promise.resolve()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
await waitFor(() =>
|
|
483
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
484
|
+
'true',
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
await waitFor(() =>
|
|
488
|
+
expect(screen.getByTestId('child').textContent).toBe('1'),
|
|
489
|
+
)
|
|
490
|
+
} finally {
|
|
491
|
+
await unmountHydratedRoot(root, container)
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('blocks hydration on awaited procedural prefetch work', async () => {
|
|
496
|
+
const preload = vi.fn(() => Promise.resolve())
|
|
497
|
+
let resolvePrefetch!: () => void
|
|
498
|
+
const prefetchBlocker = new Promise<void>((resolve) => {
|
|
499
|
+
resolvePrefetch = resolve
|
|
500
|
+
})
|
|
501
|
+
const waitReasons: Array<string> = []
|
|
502
|
+
const neverPrefetches = {
|
|
503
|
+
_t: 'idle',
|
|
504
|
+
_s: () => () => {},
|
|
505
|
+
} as HydrationPrefetchStrategy<'idle'>
|
|
506
|
+
|
|
507
|
+
const { container, root } = await hydrateFromServer(
|
|
508
|
+
<InternalHydrate
|
|
509
|
+
when={interaction()}
|
|
510
|
+
prefetch={async ({ waitFor, preload }) => {
|
|
511
|
+
waitReasons.push(await waitFor(neverPrefetches))
|
|
512
|
+
await preload()
|
|
513
|
+
await prefetchBlocker
|
|
514
|
+
}}
|
|
515
|
+
p={preload}
|
|
516
|
+
>
|
|
517
|
+
<InteractiveChild />
|
|
518
|
+
</InternalHydrate>,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
await fireIntent(() =>
|
|
523
|
+
getMarker().dispatchEvent(
|
|
524
|
+
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
await waitFor(() => expect(waitReasons).toEqual(['hydrate']))
|
|
529
|
+
expect(preload).toHaveBeenCalledTimes(1)
|
|
530
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
531
|
+
'false',
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
await act(async () => {
|
|
535
|
+
resolvePrefetch()
|
|
536
|
+
await prefetchBlocker
|
|
537
|
+
await Promise.resolve()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
await waitFor(() =>
|
|
541
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
542
|
+
'true',
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
} finally {
|
|
546
|
+
await unmountHydratedRoot(root, container)
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('hydrates when a condition strategy changes after the initial render', async () => {
|
|
551
|
+
function ConditionHarness() {
|
|
552
|
+
const [ready, setReady] = React.useState(false)
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<>
|
|
556
|
+
<button data-testid="ready" onClick={() => setReady(true)}>
|
|
557
|
+
ready
|
|
558
|
+
</button>
|
|
559
|
+
<Hydrate when={condition(ready)}>
|
|
560
|
+
<InteractiveChild />
|
|
561
|
+
</Hydrate>
|
|
562
|
+
</>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const { container, root } = await hydrateFromServer(<ConditionHarness />)
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
570
|
+
'false',
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
await act(async () => {
|
|
574
|
+
fireEvent.click(screen.getByTestId('ready'))
|
|
575
|
+
await Promise.resolve()
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
await waitFor(() =>
|
|
579
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
580
|
+
'true',
|
|
581
|
+
),
|
|
582
|
+
)
|
|
583
|
+
} finally {
|
|
584
|
+
await unmountHydratedRoot(root, container)
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it('does not block hydration on fire-and-forget procedural prefetch work', async () => {
|
|
589
|
+
let resolvePrefetch!: () => void
|
|
590
|
+
const prefetchBlocker = new Promise<void>((resolve) => {
|
|
591
|
+
resolvePrefetch = resolve
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
const { container, root } = await hydrateFromServer(
|
|
595
|
+
<InternalHydrate
|
|
596
|
+
when={interaction()}
|
|
597
|
+
prefetch={() => {
|
|
598
|
+
void prefetchBlocker
|
|
599
|
+
}}
|
|
600
|
+
>
|
|
601
|
+
<InteractiveChild />
|
|
602
|
+
</InternalHydrate>,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
await fireIntent(() =>
|
|
607
|
+
getMarker().dispatchEvent(
|
|
608
|
+
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
await waitFor(() =>
|
|
613
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
614
|
+
'true',
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
await act(async () => {
|
|
619
|
+
resolvePrefetch()
|
|
620
|
+
await prefetchBlocker
|
|
621
|
+
})
|
|
622
|
+
} finally {
|
|
623
|
+
await unmountHydratedRoot(root, container)
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('aborts procedural prefetch when the boundary unmounts', async () => {
|
|
628
|
+
const signals: Array<AbortSignal> = []
|
|
629
|
+
|
|
630
|
+
const { container, root } = await hydrateFromServer(
|
|
631
|
+
<InternalHydrate
|
|
632
|
+
when={interaction()}
|
|
633
|
+
prefetch={({ signal }) => {
|
|
634
|
+
signals.push(signal)
|
|
635
|
+
return new Promise<void>(() => {})
|
|
636
|
+
}}
|
|
637
|
+
>
|
|
638
|
+
<InteractiveChild />
|
|
639
|
+
</InternalHydrate>,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
expect(signals).toHaveLength(1)
|
|
643
|
+
expect(signals[0]!.aborted).toBe(false)
|
|
644
|
+
|
|
645
|
+
await unmountHydratedRoot(root, container)
|
|
646
|
+
expect(signals[0]!.aborted).toBe(true)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('delegates nested interaction boundaries at runtime', async () => {
|
|
650
|
+
const { container, root } = await hydrateFromServer(
|
|
651
|
+
<Hydrate when={idle({ timeout: 1000 })}>
|
|
652
|
+
<Hydrate when={interaction()}>
|
|
653
|
+
<InteractiveChild />
|
|
654
|
+
</Hydrate>
|
|
655
|
+
</Hydrate>,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
660
|
+
'false',
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
await fireIntent(() => {
|
|
664
|
+
fireEvent.click(screen.getByTestId('child'))
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
await waitFor(() =>
|
|
668
|
+
expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
|
|
669
|
+
'true',
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
} finally {
|
|
673
|
+
await unmountHydratedRoot(root, container)
|
|
674
|
+
}
|
|
675
|
+
})
|
|
676
|
+
})
|