@pyreon/router 0.24.5 → 0.24.6

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.
@@ -1,18 +0,0 @@
1
- import { isNativeCompat } from '@pyreon/core'
2
- import { describe, expect, it } from 'vitest'
3
- import { RouterLink, RouterProvider, RouterView } from '../components'
4
-
5
- // Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
6
- // any of the `nativeCompat(...)` calls in components.tsx fails the
7
- // corresponding test.
8
- describe('native-compat markers — @pyreon/router', () => {
9
- it('RouterProvider is marked native', () => {
10
- expect(isNativeCompat(RouterProvider)).toBe(true)
11
- })
12
- it('RouterView is marked native', () => {
13
- expect(isNativeCompat(RouterView)).toBe(true)
14
- })
15
- it('RouterLink is marked native', () => {
16
- expect(isNativeCompat(RouterLink)).toBe(true)
17
- })
18
- })
@@ -1,96 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { getRedirectInfo, isRedirectError, redirect } from '../redirect'
3
-
4
- describe('redirect()', () => {
5
- it('throws an error branded with the REDIRECT symbol', () => {
6
- expect(() => redirect('/login')).toThrow()
7
- })
8
-
9
- it('captures URL + default 307 status on the thrown error', () => {
10
- let caught: unknown
11
- try {
12
- redirect('/login')
13
- } catch (err) {
14
- caught = err
15
- }
16
- const info = getRedirectInfo(caught)
17
- expect(info).toEqual({ url: '/login', status: 307 })
18
- })
19
-
20
- it('captures the custom status when one is provided', () => {
21
- let caught: unknown
22
- try {
23
- redirect('/perm', 308)
24
- } catch (err) {
25
- caught = err
26
- }
27
- expect(getRedirectInfo(caught)?.status).toBe(308)
28
- })
29
-
30
- it.each([301, 302, 303, 307, 308] as const)('accepts %s as a valid status', (status) => {
31
- let caught: unknown
32
- try {
33
- redirect('/x', status)
34
- } catch (err) {
35
- caught = err
36
- }
37
- expect(getRedirectInfo(caught)?.status).toBe(status)
38
- })
39
-
40
- it('produces a human-readable Error message', () => {
41
- let caught: Error | undefined
42
- try {
43
- redirect('/login')
44
- } catch (err) {
45
- caught = err as Error
46
- }
47
- expect(caught?.message).toBe('Redirect to /login')
48
- })
49
- })
50
-
51
- describe('isRedirectError()', () => {
52
- it('returns true for an error thrown by redirect()', () => {
53
- let caught: unknown
54
- try {
55
- redirect('/x')
56
- } catch (err) {
57
- caught = err
58
- }
59
- expect(isRedirectError(caught)).toBe(true)
60
- })
61
-
62
- it('returns false for a plain Error', () => {
63
- expect(isRedirectError(new Error('plain'))).toBe(false)
64
- })
65
-
66
- it('returns false for non-error values', () => {
67
- expect(isRedirectError(null)).toBe(false)
68
- expect(isRedirectError(undefined)).toBe(false)
69
- expect(isRedirectError('string')).toBe(false)
70
- expect(isRedirectError(42)).toBe(false)
71
- expect(isRedirectError({})).toBe(false)
72
- })
73
-
74
- it('returns false for objects with a different brand', () => {
75
- const fake = new Error('fake')
76
- ;(fake as unknown as Record<symbol, unknown>)[Symbol.for('something.else')] = true
77
- expect(isRedirectError(fake)).toBe(false)
78
- })
79
- })
80
-
81
- describe('getRedirectInfo()', () => {
82
- it('returns null for non-redirect errors', () => {
83
- expect(getRedirectInfo(new Error('plain'))).toBeNull()
84
- expect(getRedirectInfo(null)).toBeNull()
85
- })
86
-
87
- it('returns the redirect info for a thrown redirect()', () => {
88
- let caught: unknown
89
- try {
90
- redirect('/destination', 303)
91
- } catch (err) {
92
- caught = err
93
- }
94
- expect(getRedirectInfo(caught)).toEqual({ url: '/destination', status: 303 })
95
- })
96
- })
@@ -1,509 +0,0 @@
1
- import { h, onMount } 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
-
443
- // ── Lock-in for the layout-remount loop fix (PR #406) ──────────────────────
444
- //
445
- // Pre-fix `RouterView`'s reactive child accessor read `_loadingSignal()` and
446
- // the full `currentRoute` snapshot directly. Each navigation flow writes
447
- // `_loadingSignal` at least twice (start tick + end tick) and writes
448
- // `currentPath` once via `commitNavigation`. Any of those writes re-emitted
449
- // the reactive child, and `mountReactive`'s teardown-then-mount cleanup
450
- // remounted the entire matched-component subtree on each emission. So a
451
- // single `router.push()` produced 2-3+ mounts of the destination component
452
- // (instead of 1) — the "layout double/triple mount" loop.
453
- //
454
- // The fix routes the structural decision through a single
455
- // `computed<DepthEntry>` keyed on `(rec, comp, errored, route)` reference
456
- // equality. Within-navigation `_loadingSignal` ticks don't change
457
- // `currentRoute` (it's `computed` memoized on `currentPath`), so the
458
- // structural emission stays at exactly one per navigation.
459
- //
460
- // This test instruments a counter inside the destination component's
461
- // `onMount` — an inflated count after a single `await router.push()` would
462
- // mean the loop is back. Bisect-verifies against the structural decoupling
463
- // commit: reverting that commit pushes the count to ≥ 2 and this assertion
464
- // fails.
465
- it('a single router.push() mounts the destination component exactly once (loop-prevention regression)', async () => {
466
- let aboutMountCount = 0
467
- const InstrumentedAbout = () => {
468
- onMount(() => {
469
- aboutMountCount++
470
- })
471
- return h('div', { id: 'about-instrumented' }, 'About Page')
472
- }
473
- const localRoutes = [
474
- { path: '/', component: Home },
475
- { path: '/about', component: InstrumentedAbout },
476
- ]
477
- const router = createRouter({ routes: localRoutes, mode: 'hash' })
478
- const { container, unmount } = mountInBrowser(
479
- h(RouterProvider, { router }, h(RouterView, {})),
480
- )
481
-
482
- // Sanity: starting at /, About is not yet mounted.
483
- expect(aboutMountCount).toBe(0)
484
- expect(container.querySelector('#about-instrumented')).toBeNull()
485
-
486
- await router.push('/about')
487
- await flush()
488
-
489
- // The structural decoupling fix means a single navigation produces a
490
- // single emission at this depth. If `RouterView` ever reverts to reading
491
- // `_loadingSignal` reactively, every loadingSignal tick during the
492
- // navigate flow will remount the destination subtree and this count
493
- // jumps to 2 or 3.
494
- expect(container.querySelector('#about-instrumented')?.textContent).toBe('About Page')
495
- expect(aboutMountCount).toBe(1)
496
-
497
- // And navigating BACK to / + forward again to /about produces exactly
498
- // one more mount — covers the case where stale subscribers from a prior
499
- // mount could double-fire across navigations.
500
- await router.push('/')
501
- await flush()
502
- await router.push('/about')
503
- await flush()
504
- expect(aboutMountCount).toBe(2)
505
-
506
- expect(unhandledRejections).toEqual([])
507
- unmount()
508
- })
509
- })