@pyreon/router 0.12.12 → 0.12.14
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/README.md +14 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +53 -30
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +13 -0
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/router.ts +107 -33
- package/src/scroll.ts +6 -1
- package/src/tests/loader.test.ts +52 -2
- package/src/tests/router.browser.test.tsx +442 -0
- package/src/tests/router.test.ts +253 -8
- package/src/types.ts +13 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
createRouter,
|
|
6
|
+
RouterLink,
|
|
7
|
+
RouterProvider,
|
|
8
|
+
RouterView,
|
|
9
|
+
useIsActive,
|
|
10
|
+
useLoaderData,
|
|
11
|
+
useSearchParams,
|
|
12
|
+
} from '../index'
|
|
13
|
+
import { setActiveRouter } from '../router'
|
|
14
|
+
|
|
15
|
+
// Real-Chromium smoke suite for @pyreon/router.
|
|
16
|
+
//
|
|
17
|
+
// Runs in hash mode so each test mutates only `location.hash`, not the
|
|
18
|
+
// real browser URL path — keeps tests isolated from one another and from
|
|
19
|
+
// the vitest harness page. The recurring risk these tests close: happy-dom's
|
|
20
|
+
// `history.pushState` is a stub; real browsers fire `popstate`, update the
|
|
21
|
+
// address bar, and integrate with View Transitions. This suite exercises
|
|
22
|
+
// the real wiring.
|
|
23
|
+
|
|
24
|
+
const Home = () => h('div', { id: 'home' }, 'Home Page')
|
|
25
|
+
const About = () => h('div', { id: 'about' }, 'About Page')
|
|
26
|
+
const User = (props: Record<string, unknown>) => {
|
|
27
|
+
const params = props.params as Record<string, string>
|
|
28
|
+
return h('div', { id: 'user' }, `User: ${params.id}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// View Transitions stay enabled (the Chromium default). `commitNavigation`
|
|
32
|
+
// now awaits `vt.updateCallbackDone` so `await router.push()` resolves
|
|
33
|
+
// AFTER the DOM swap — no more per-route opt-outs to keep tests
|
|
34
|
+
// deterministic.
|
|
35
|
+
const routes = [
|
|
36
|
+
{ path: '/', component: Home },
|
|
37
|
+
{ path: '/about', component: About },
|
|
38
|
+
{ path: '/user/:id', component: User },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
describe('router in real browser', () => {
|
|
42
|
+
// Track unhandled promise rejections across each test so regressions
|
|
43
|
+
// of the "Transition was skipped AbortError leaks as unhandled
|
|
44
|
+
// rejection" bug fail loudly instead of silently polluting the run.
|
|
45
|
+
const unhandledRejections: unknown[] = []
|
|
46
|
+
const onUnhandledRejection = (e: PromiseRejectionEvent) => {
|
|
47
|
+
unhandledRejections.push(e.reason)
|
|
48
|
+
// Let vitest's own handler still see the event (so other unrelated
|
|
49
|
+
// regressions still surface).
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
// Reset hash so each test starts at '/'.
|
|
54
|
+
window.location.hash = ''
|
|
55
|
+
unhandledRejections.length = 0
|
|
56
|
+
window.addEventListener('unhandledrejection', onUnhandledRejection)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
window.removeEventListener('unhandledrejection', onUnhandledRejection)
|
|
61
|
+
setActiveRouter(null)
|
|
62
|
+
window.location.hash = ''
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('mounts the initial route and renders its component', async () => {
|
|
66
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
67
|
+
const { container, unmount } = mountInBrowser(
|
|
68
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(container.querySelector('#home')?.textContent).toBe('Home Page')
|
|
72
|
+
unmount()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('router.push() updates both the DOM and location.hash', async () => {
|
|
76
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
77
|
+
const { container, unmount } = mountInBrowser(
|
|
78
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
await router.push('/about')
|
|
82
|
+
await flush()
|
|
83
|
+
|
|
84
|
+
expect(container.querySelector('#about')?.textContent).toBe('About Page')
|
|
85
|
+
expect(container.querySelector('#home')).toBeNull()
|
|
86
|
+
expect(window.location.hash).toBe('#/about')
|
|
87
|
+
unmount()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('resolves dynamic :params from the URL', async () => {
|
|
91
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
92
|
+
const { container, unmount } = mountInBrowser(
|
|
93
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
await router.push('/user/42')
|
|
97
|
+
await flush()
|
|
98
|
+
expect(container.querySelector('#user')?.textContent).toBe('User: 42')
|
|
99
|
+
|
|
100
|
+
await router.push('/user/99')
|
|
101
|
+
await flush()
|
|
102
|
+
expect(container.querySelector('#user')?.textContent).toBe('User: 99')
|
|
103
|
+
unmount()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('RouterLink click triggers navigation without a full page load', async () => {
|
|
107
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
108
|
+
const { container, unmount } = mountInBrowser(
|
|
109
|
+
h(
|
|
110
|
+
RouterProvider,
|
|
111
|
+
{ router },
|
|
112
|
+
h(
|
|
113
|
+
'div',
|
|
114
|
+
null,
|
|
115
|
+
h(RouterLink, { to: '/about', id: 'link' }, 'Go to about'),
|
|
116
|
+
h(RouterView, {}),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const link = container.querySelector<HTMLAnchorElement>('#link')
|
|
122
|
+
expect(link).not.toBeNull()
|
|
123
|
+
link!.click()
|
|
124
|
+
|
|
125
|
+
// RouterLink's onClick calls router.push() which is async; give it a tick.
|
|
126
|
+
await flush()
|
|
127
|
+
|
|
128
|
+
expect(container.querySelector('#about')?.textContent).toBe('About Page')
|
|
129
|
+
expect(window.location.hash).toBe('#/about')
|
|
130
|
+
unmount()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('View Transitions API — `await router.push()` resolves AFTER the DOM swap', async () => {
|
|
134
|
+
// Regression for the bug fixed alongside this PR:
|
|
135
|
+
// commitNavigation() was sync, so when the browser ran `doCommit`
|
|
136
|
+
// inside `startViewTransition(cb)`, `await router.push()` resolved
|
|
137
|
+
// BEFORE `cb` fired. Browser tests had to opt out of View
|
|
138
|
+
// Transitions per-route to stay deterministic.
|
|
139
|
+
//
|
|
140
|
+
// After the fix, commitNavigation awaits `vt.updateCallbackDone`;
|
|
141
|
+
// `await router.push()` resolves once the DOM state is live (but
|
|
142
|
+
// before the animation finishes, which would block 200-300ms).
|
|
143
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
144
|
+
const { container, unmount } = mountInBrowser(
|
|
145
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
146
|
+
)
|
|
147
|
+
expect(container.querySelector('#home')).not.toBeNull()
|
|
148
|
+
|
|
149
|
+
// Sanity: Chromium exposes the API this test exercises.
|
|
150
|
+
expect(
|
|
151
|
+
typeof (document as unknown as { startViewTransition?: unknown }).startViewTransition,
|
|
152
|
+
).toBe('function')
|
|
153
|
+
|
|
154
|
+
await router.push('/about')
|
|
155
|
+
// No polling — immediately after the await, the DOM MUST reflect
|
|
156
|
+
// the new route. If this ever regresses, the rest of the suite
|
|
157
|
+
// will fail too.
|
|
158
|
+
expect(container.querySelector('#about')?.textContent).toBe('About Page')
|
|
159
|
+
expect(container.querySelector('#home')).toBeNull()
|
|
160
|
+
unmount()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('popstate (browser back/forward) navigates', async () => {
|
|
164
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
165
|
+
const { container, unmount } = mountInBrowser(
|
|
166
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await router.push('/about')
|
|
170
|
+
await flush()
|
|
171
|
+
expect(container.querySelector('#about')).not.toBeNull()
|
|
172
|
+
|
|
173
|
+
// Simulate user pressing back: reset hash + dispatch hashchange.
|
|
174
|
+
// Hash mode listens to `hashchange` — mirrors real browser behavior.
|
|
175
|
+
window.location.hash = '#/'
|
|
176
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'))
|
|
177
|
+
|
|
178
|
+
await flush()
|
|
179
|
+
|
|
180
|
+
expect(container.querySelector('#home')?.textContent).toBe('Home Page')
|
|
181
|
+
expect(container.querySelector('#about')).toBeNull()
|
|
182
|
+
unmount()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('useIsActive() reactively flips when the route changes', async () => {
|
|
186
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
187
|
+
const ActiveBadge = () => {
|
|
188
|
+
const isAbout = useIsActive('/about', true)
|
|
189
|
+
return h('span', { id: 'badge' }, () => (isAbout() ? 'on-about' : 'off-about'))
|
|
190
|
+
}
|
|
191
|
+
const { container, unmount } = mountInBrowser(
|
|
192
|
+
h(
|
|
193
|
+
RouterProvider,
|
|
194
|
+
{ router },
|
|
195
|
+
h('div', null, h(ActiveBadge, {}), h(RouterView, {})),
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
expect(container.querySelector('#badge')?.textContent).toBe('off-about')
|
|
199
|
+
|
|
200
|
+
await router.push('/about')
|
|
201
|
+
await flush()
|
|
202
|
+
expect(container.querySelector('#badge')?.textContent).toBe('on-about')
|
|
203
|
+
|
|
204
|
+
await router.push('/')
|
|
205
|
+
await flush()
|
|
206
|
+
expect(container.querySelector('#badge')?.textContent).toBe('off-about')
|
|
207
|
+
unmount()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('beforeEnter guard returning false blocks navigation (DOM unchanged)', async () => {
|
|
211
|
+
const guardedRoutes = [
|
|
212
|
+
{ path: '/', component: Home },
|
|
213
|
+
{
|
|
214
|
+
path: '/protected',
|
|
215
|
+
component: About,
|
|
216
|
+
beforeEnter: () => false,
|
|
217
|
+
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
const router = createRouter({ routes: guardedRoutes, mode: 'hash' })
|
|
221
|
+
const { container, unmount } = mountInBrowser(
|
|
222
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
223
|
+
)
|
|
224
|
+
expect(container.querySelector('#home')).not.toBeNull()
|
|
225
|
+
|
|
226
|
+
await router.push('/protected')
|
|
227
|
+
await flush()
|
|
228
|
+
// Still on home — guard blocked.
|
|
229
|
+
expect(container.querySelector('#home')).not.toBeNull()
|
|
230
|
+
expect(container.querySelector('#about')).toBeNull()
|
|
231
|
+
unmount()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('beforeEnter guard returning a string redirects', async () => {
|
|
235
|
+
const Redirected = () => h('div', { id: 'redirected' }, 'Redirected')
|
|
236
|
+
const guardRedirect = [
|
|
237
|
+
{ path: '/', component: Home },
|
|
238
|
+
{
|
|
239
|
+
path: '/old',
|
|
240
|
+
component: About,
|
|
241
|
+
beforeEnter: () => '/new',
|
|
242
|
+
|
|
243
|
+
},
|
|
244
|
+
{ path: '/new', component: Redirected },
|
|
245
|
+
]
|
|
246
|
+
const router = createRouter({ routes: guardRedirect, mode: 'hash' })
|
|
247
|
+
const { container, unmount } = mountInBrowser(
|
|
248
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
await router.push('/old')
|
|
252
|
+
await flush()
|
|
253
|
+
expect(container.querySelector('#redirected')?.textContent).toBe('Redirected')
|
|
254
|
+
unmount()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('static `redirect` field on route record forwards', async () => {
|
|
258
|
+
const Target = () => h('div', { id: 'tgt' }, 'Target')
|
|
259
|
+
const redirRoutes = [
|
|
260
|
+
{ path: '/', component: Home },
|
|
261
|
+
{ path: '/source', redirect: '/target', component: Home },
|
|
262
|
+
{ path: '/target', component: Target },
|
|
263
|
+
]
|
|
264
|
+
const router = createRouter({ routes: redirRoutes, mode: 'hash' })
|
|
265
|
+
const { container, unmount } = mountInBrowser(
|
|
266
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
267
|
+
)
|
|
268
|
+
await router.push('/source')
|
|
269
|
+
await flush()
|
|
270
|
+
expect(container.querySelector('#tgt')?.textContent).toBe('Target')
|
|
271
|
+
unmount()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('route loader runs before component renders; useLoaderData reads the result', async () => {
|
|
275
|
+
const seen: string[] = []
|
|
276
|
+
const Profile = () => {
|
|
277
|
+
const data = useLoaderData<{ user: string }>()
|
|
278
|
+
seen.push(`render:${data.user}`)
|
|
279
|
+
return h('div', { id: 'profile' }, () => `User: ${data.user}`)
|
|
280
|
+
}
|
|
281
|
+
const loaderRoutes = [
|
|
282
|
+
{ path: '/', component: Home },
|
|
283
|
+
{
|
|
284
|
+
path: '/profile',
|
|
285
|
+
component: Profile,
|
|
286
|
+
loader: async () => {
|
|
287
|
+
await Promise.resolve()
|
|
288
|
+
seen.push('loader')
|
|
289
|
+
return { user: 'Alice' }
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
},
|
|
293
|
+
]
|
|
294
|
+
const router = createRouter({ routes: loaderRoutes, mode: 'hash' })
|
|
295
|
+
const { container, unmount } = mountInBrowser(
|
|
296
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
297
|
+
)
|
|
298
|
+
await router.push('/profile')
|
|
299
|
+
await flush()
|
|
300
|
+
|
|
301
|
+
expect(container.querySelector('#profile')?.textContent).toBe('User: Alice')
|
|
302
|
+
// Loader runs before render.
|
|
303
|
+
expect(seen[0]).toBe('loader')
|
|
304
|
+
unmount()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('useSearchParams reads typed search-string + signal updates from push', async () => {
|
|
308
|
+
const Search = () => {
|
|
309
|
+
const [params] = useSearchParams()
|
|
310
|
+
return h('div', { id: 'q' }, () => `q=${params().q ?? ''}`)
|
|
311
|
+
}
|
|
312
|
+
const searchRoutes = [
|
|
313
|
+
{ path: '/', component: Home },
|
|
314
|
+
{ path: '/search', component: Search },
|
|
315
|
+
]
|
|
316
|
+
const router = createRouter({ routes: searchRoutes, mode: 'hash' })
|
|
317
|
+
const { container, unmount } = mountInBrowser(
|
|
318
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
319
|
+
)
|
|
320
|
+
await router.push('/search?q=hello')
|
|
321
|
+
await flush()
|
|
322
|
+
expect(container.querySelector('#q')?.textContent).toBe('q=hello')
|
|
323
|
+
|
|
324
|
+
await router.push('/search?q=world')
|
|
325
|
+
await flush()
|
|
326
|
+
expect(container.querySelector('#q')?.textContent).toBe('q=world')
|
|
327
|
+
unmount()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('named navigation: router.push({ name, params }) resolves to URL', async () => {
|
|
331
|
+
const namedRoutes = [
|
|
332
|
+
{ path: '/', component: Home },
|
|
333
|
+
{ path: '/user/:id', component: User, name: 'user' },
|
|
334
|
+
]
|
|
335
|
+
const router = createRouter({ routes: namedRoutes, mode: 'hash' })
|
|
336
|
+
const { container, unmount } = mountInBrowser(
|
|
337
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await router.push({ name: 'user', params: { id: '7' } })
|
|
341
|
+
await flush()
|
|
342
|
+
expect(container.querySelector('#user')?.textContent).toBe('User: 7')
|
|
343
|
+
expect(window.location.hash).toBe('#/user/7')
|
|
344
|
+
unmount()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('catch-all wildcard route renders for unknown paths', async () => {
|
|
348
|
+
const NotFound = () => h('div', { id: 'nf' }, 'Not found')
|
|
349
|
+
const wildcardRoutes = [
|
|
350
|
+
{ path: '/', component: Home },
|
|
351
|
+
{ path: '*', component: NotFound },
|
|
352
|
+
]
|
|
353
|
+
const router = createRouter({ routes: wildcardRoutes, mode: 'hash' })
|
|
354
|
+
const { container, unmount } = mountInBrowser(
|
|
355
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
356
|
+
)
|
|
357
|
+
await router.push('/totally/does/not/exist')
|
|
358
|
+
await flush()
|
|
359
|
+
expect(container.querySelector('#nf')).not.toBeNull()
|
|
360
|
+
unmount()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('RouterLink with replace=true does NOT add to history', async () => {
|
|
364
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
365
|
+
const { container, unmount } = mountInBrowser(
|
|
366
|
+
h(
|
|
367
|
+
RouterProvider,
|
|
368
|
+
{ router },
|
|
369
|
+
h(
|
|
370
|
+
'div',
|
|
371
|
+
null,
|
|
372
|
+
h(RouterLink, { to: '/about', id: 'l1' }, 'about'),
|
|
373
|
+
h(RouterLink, { to: '/', id: 'l2', replace: true }, 'home-replace'),
|
|
374
|
+
h(RouterView, {}),
|
|
375
|
+
),
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
container.querySelector<HTMLAnchorElement>('#l1')!.click()
|
|
380
|
+
await flush()
|
|
381
|
+
expect(container.querySelector('#about')).not.toBeNull()
|
|
382
|
+
|
|
383
|
+
const histLenBefore = window.history.length
|
|
384
|
+
container.querySelector<HTMLAnchorElement>('#l2')!.click()
|
|
385
|
+
await flush()
|
|
386
|
+
expect(container.querySelector('#home')).not.toBeNull()
|
|
387
|
+
// replace navigation should not increase history length.
|
|
388
|
+
expect(window.history.length).toBe(histLenBefore)
|
|
389
|
+
unmount()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('rapid push() calls — only the final destination wins, no unhandled rejections', async () => {
|
|
393
|
+
// Each push() starts a new ViewTransition. The older in-flight
|
|
394
|
+
// transition(s) get skipped, which makes their `.ready` and
|
|
395
|
+
// `.finished` promises reject with `AbortError: Transition was
|
|
396
|
+
// skipped`. The router installs `.catch(() => {})` on both so the
|
|
397
|
+
// rejections don't escape. This test asserts both contracts:
|
|
398
|
+
// 1. Final destination resolves correctly.
|
|
399
|
+
// 2. `window.onunhandledrejection` fires zero times.
|
|
400
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
401
|
+
const { container, unmount } = mountInBrowser(
|
|
402
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
// Don't await — fire all three before any has settled.
|
|
406
|
+
const p1 = router.push('/about')
|
|
407
|
+
const p2 = router.push('/user/1')
|
|
408
|
+
const p3 = router.push('/user/2')
|
|
409
|
+
await Promise.all([p1, p2, p3])
|
|
410
|
+
await flush()
|
|
411
|
+
|
|
412
|
+
// Final destination resolves and renders.
|
|
413
|
+
expect(container.querySelector('#user')?.textContent).toBe('User: 2')
|
|
414
|
+
expect(container.querySelector('#about')).toBeNull()
|
|
415
|
+
expect(window.location.hash).toBe('#/user/2')
|
|
416
|
+
|
|
417
|
+
// Give the microtask queue a chance to surface any leaked rejection.
|
|
418
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
419
|
+
expect(unhandledRejections).toEqual([])
|
|
420
|
+
unmount()
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('await router.replace() resolves after DOM swap (same VT contract as push)', async () => {
|
|
424
|
+
// `push` and `replace` both go through `navigate()` which awaits
|
|
425
|
+
// `commitNavigation()`. This test locks in that the DOM-after-await
|
|
426
|
+
// contract holds for replace() too — so a future refactor that
|
|
427
|
+
// splits their code paths can't silently regress one without the
|
|
428
|
+
// other.
|
|
429
|
+
const router = createRouter({ routes, mode: 'hash' })
|
|
430
|
+
const { container, unmount } = mountInBrowser(
|
|
431
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
432
|
+
)
|
|
433
|
+
expect(container.querySelector('#home')).not.toBeNull()
|
|
434
|
+
|
|
435
|
+
await router.replace('/about')
|
|
436
|
+
// Immediate — no polling, no flush(). If the VT await chain skips
|
|
437
|
+
// replace(), this assertion fails.
|
|
438
|
+
expect(container.querySelector('#about')?.textContent).toBe('About Page')
|
|
439
|
+
expect(container.querySelector('#home')).toBeNull()
|
|
440
|
+
unmount()
|
|
441
|
+
})
|
|
442
|
+
})
|