@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.
- package/package.json +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
|
@@ -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
|
-
})
|