@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.
@@ -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
+ })