@pyreon/router 0.11.4 → 0.11.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/README.md +14 -12
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +9 -9
- package/package.json +13 -13
- package/src/components.tsx +23 -23
- package/src/index.ts +7 -7
- package/src/loader.ts +4 -4
- package/src/match.ts +41 -41
- package/src/router.ts +86 -86
- package/src/scroll.ts +12 -12
- package/src/tests/loader.test.ts +210 -210
- package/src/tests/match.test.ts +202 -202
- package/src/tests/router.test.ts +1483 -1422
- package/src/tests/setup.ts +1 -1
- package/src/types.ts +12 -12
package/src/tests/loader.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData } from
|
|
2
|
-
import { createRouter, setActiveRouter, useIsActive, useSearchParams } from
|
|
3
|
-
import type { RouteRecord, RouterInstance } from
|
|
1
|
+
import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData } from '../loader'
|
|
2
|
+
import { createRouter, setActiveRouter, useIsActive, useSearchParams } from '../router'
|
|
3
|
+
import type { RouteRecord, RouterInstance } from '../types'
|
|
4
4
|
|
|
5
5
|
const Home = () => null
|
|
6
6
|
const About = () => null
|
|
@@ -8,44 +8,44 @@ const User = () => null
|
|
|
8
8
|
|
|
9
9
|
// ─── serializeLoaderData / hydrateLoaderData round-trip edge cases ────────────
|
|
10
10
|
|
|
11
|
-
describe(
|
|
12
|
-
test(
|
|
11
|
+
describe('loader data serialization — edge cases', () => {
|
|
12
|
+
test('serializes multiple route loaders', async () => {
|
|
13
13
|
const routes: RouteRecord[] = [
|
|
14
14
|
{
|
|
15
|
-
path:
|
|
15
|
+
path: '/admin',
|
|
16
16
|
component: Home,
|
|
17
|
-
loader: async () =>
|
|
17
|
+
loader: async () => 'admin-data',
|
|
18
18
|
children: [
|
|
19
19
|
{
|
|
20
|
-
path:
|
|
20
|
+
path: 'users',
|
|
21
21
|
component: About,
|
|
22
|
-
loader: async () =>
|
|
22
|
+
loader: async () => 'users-data',
|
|
23
23
|
},
|
|
24
24
|
],
|
|
25
25
|
},
|
|
26
26
|
]
|
|
27
|
-
const router = createRouter({ routes, url:
|
|
28
|
-
await prefetchLoaderData(router,
|
|
27
|
+
const router = createRouter({ routes, url: '/admin/users' }) as RouterInstance
|
|
28
|
+
await prefetchLoaderData(router, '/admin/users')
|
|
29
29
|
|
|
30
30
|
const serialized = serializeLoaderData(router)
|
|
31
|
-
expect(serialized[
|
|
32
|
-
expect(serialized.users).toBe(
|
|
31
|
+
expect(serialized['/admin']).toBe('admin-data')
|
|
32
|
+
expect(serialized.users).toBe('users-data')
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
test(
|
|
35
|
+
test('hydrate ignores paths not in current route matched', () => {
|
|
36
36
|
const routes: RouteRecord[] = [
|
|
37
|
-
{ path:
|
|
38
|
-
{ path:
|
|
37
|
+
{ path: '/', component: Home },
|
|
38
|
+
{ path: '/page', component: About, loader: async () => [] },
|
|
39
39
|
]
|
|
40
|
-
const router = createRouter({ routes, url:
|
|
40
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
41
41
|
// Hydrate with data for a path that is NOT currently matched
|
|
42
|
-
hydrateLoaderData(router, {
|
|
42
|
+
hydrateLoaderData(router, { '/page': 'should-be-ignored' })
|
|
43
43
|
expect(router._loaderData.size).toBe(0)
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
test(
|
|
47
|
-
const routes: RouteRecord[] = [{ path:
|
|
48
|
-
const router = createRouter({ routes, url:
|
|
46
|
+
test('hydrate with non-object values is no-op', () => {
|
|
47
|
+
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
48
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
49
49
|
|
|
50
50
|
// These should not throw
|
|
51
51
|
hydrateLoaderData(router, null as unknown as Record<string, unknown>)
|
|
@@ -54,313 +54,313 @@ describe("loader data serialization — edge cases", () => {
|
|
|
54
54
|
expect(router._loaderData.size).toBe(0)
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
test(
|
|
57
|
+
test('round-trip with complex data types', async () => {
|
|
58
58
|
const complexData = {
|
|
59
59
|
items: [
|
|
60
|
-
{ id: 1, name:
|
|
61
|
-
{ id: 2, name:
|
|
60
|
+
{ id: 1, name: 'Item 1' },
|
|
61
|
+
{ id: 2, name: 'Item 2' },
|
|
62
62
|
],
|
|
63
63
|
meta: { total: 2, page: 1 },
|
|
64
64
|
nested: { deep: { value: true } },
|
|
65
65
|
}
|
|
66
66
|
const routes: RouteRecord[] = [
|
|
67
|
-
{ path:
|
|
67
|
+
{ path: '/data', component: Home, loader: async () => complexData },
|
|
68
68
|
]
|
|
69
|
-
const ssrRouter = createRouter({ routes, url:
|
|
70
|
-
await prefetchLoaderData(ssrRouter,
|
|
69
|
+
const ssrRouter = createRouter({ routes, url: '/data' }) as RouterInstance
|
|
70
|
+
await prefetchLoaderData(ssrRouter, '/data')
|
|
71
71
|
|
|
72
72
|
const serialized = serializeLoaderData(ssrRouter)
|
|
73
|
-
const clientRouter = createRouter({ routes, url:
|
|
73
|
+
const clientRouter = createRouter({ routes, url: '/data' }) as RouterInstance
|
|
74
74
|
hydrateLoaderData(clientRouter, serialized)
|
|
75
75
|
|
|
76
76
|
const values = Array.from(clientRouter._loaderData.values())
|
|
77
77
|
expect(values[0]).toEqual(complexData)
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
test(
|
|
80
|
+
test('prefetchLoaderData passes AbortSignal to loaders', async () => {
|
|
81
81
|
let receivedSignal: AbortSignal | undefined
|
|
82
82
|
const routes: RouteRecord[] = [
|
|
83
83
|
{
|
|
84
|
-
path:
|
|
84
|
+
path: '/data',
|
|
85
85
|
component: Home,
|
|
86
86
|
loader: async ({ signal }) => {
|
|
87
87
|
receivedSignal = signal
|
|
88
|
-
return
|
|
88
|
+
return 'ok'
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
91
|
]
|
|
92
|
-
const router = createRouter({ routes, url:
|
|
93
|
-
await prefetchLoaderData(router,
|
|
92
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
93
|
+
await prefetchLoaderData(router, '/data')
|
|
94
94
|
expect(receivedSignal).toBeDefined()
|
|
95
95
|
expect(receivedSignal).toBeInstanceOf(AbortSignal)
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
-
test(
|
|
98
|
+
test('prefetchLoaderData skips routes without loaders', async () => {
|
|
99
99
|
const routes: RouteRecord[] = [
|
|
100
100
|
{
|
|
101
|
-
path:
|
|
101
|
+
path: '/admin',
|
|
102
102
|
component: Home,
|
|
103
103
|
children: [
|
|
104
|
-
{ path:
|
|
104
|
+
{ path: 'users', component: About }, // no loader
|
|
105
105
|
],
|
|
106
106
|
},
|
|
107
107
|
]
|
|
108
|
-
const router = createRouter({ routes, url:
|
|
109
|
-
await prefetchLoaderData(router,
|
|
108
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
109
|
+
await prefetchLoaderData(router, '/admin/users')
|
|
110
110
|
expect(router._loaderData.size).toBe(0)
|
|
111
111
|
})
|
|
112
112
|
})
|
|
113
113
|
|
|
114
114
|
// ─── useIsActive — edge cases ────────────────────────────────────────────────
|
|
115
115
|
|
|
116
|
-
describe(
|
|
116
|
+
describe('useIsActive — edge cases', () => {
|
|
117
117
|
beforeEach(() => {
|
|
118
118
|
setActiveRouter(null)
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
-
test(
|
|
122
|
-
expect(() => useIsActive(
|
|
121
|
+
test('throws when no router installed', () => {
|
|
122
|
+
expect(() => useIsActive('/')).toThrow('[pyreon-router] No router installed')
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
test(
|
|
126
|
-
const router = createRouter({ routes: [{ path:
|
|
125
|
+
test('exact match for root path', () => {
|
|
126
|
+
const router = createRouter({ routes: [{ path: '/', component: Home }], url: '/' })
|
|
127
127
|
setActiveRouter(router as RouterInstance)
|
|
128
|
-
const isActive = useIsActive(
|
|
128
|
+
const isActive = useIsActive('/', true)
|
|
129
129
|
expect(isActive()).toBe(true)
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
-
test(
|
|
132
|
+
test('partial match: /admin matches /admin/users', () => {
|
|
133
133
|
const routes: RouteRecord[] = [
|
|
134
134
|
{
|
|
135
|
-
path:
|
|
135
|
+
path: '/admin',
|
|
136
136
|
component: Home,
|
|
137
|
-
children: [{ path:
|
|
137
|
+
children: [{ path: 'users', component: About }],
|
|
138
138
|
},
|
|
139
139
|
]
|
|
140
|
-
const router = createRouter({ routes, url:
|
|
140
|
+
const router = createRouter({ routes, url: '/admin/users' })
|
|
141
141
|
setActiveRouter(router as RouterInstance)
|
|
142
|
-
const isActive = useIsActive(
|
|
142
|
+
const isActive = useIsActive('/admin')
|
|
143
143
|
expect(isActive()).toBe(true)
|
|
144
144
|
})
|
|
145
145
|
|
|
146
|
-
test(
|
|
146
|
+
test('partial match: /admin does NOT match /admin-panel', async () => {
|
|
147
147
|
const routes: RouteRecord[] = [
|
|
148
|
-
{ path:
|
|
149
|
-
{ path:
|
|
148
|
+
{ path: '/admin', component: Home },
|
|
149
|
+
{ path: '/admin-panel', component: About },
|
|
150
150
|
]
|
|
151
|
-
const router = createRouter({ routes, url:
|
|
151
|
+
const router = createRouter({ routes, url: '/admin-panel' })
|
|
152
152
|
setActiveRouter(router as RouterInstance)
|
|
153
|
-
const isActive = useIsActive(
|
|
153
|
+
const isActive = useIsActive('/admin')
|
|
154
154
|
expect(isActive()).toBe(false)
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
-
test(
|
|
157
|
+
test('exact match: /admin does NOT match /admin/users', () => {
|
|
158
158
|
const routes: RouteRecord[] = [
|
|
159
159
|
{
|
|
160
|
-
path:
|
|
160
|
+
path: '/admin',
|
|
161
161
|
component: Home,
|
|
162
|
-
children: [{ path:
|
|
162
|
+
children: [{ path: 'users', component: About }],
|
|
163
163
|
},
|
|
164
164
|
]
|
|
165
|
-
const router = createRouter({ routes, url:
|
|
165
|
+
const router = createRouter({ routes, url: '/admin/users' })
|
|
166
166
|
setActiveRouter(router as RouterInstance)
|
|
167
|
-
const isActive = useIsActive(
|
|
167
|
+
const isActive = useIsActive('/admin', true)
|
|
168
168
|
expect(isActive()).toBe(false)
|
|
169
169
|
})
|
|
170
170
|
|
|
171
|
-
test(
|
|
171
|
+
test('root path partial match only matches /', () => {
|
|
172
172
|
const routes: RouteRecord[] = [
|
|
173
|
-
{ path:
|
|
174
|
-
{ path:
|
|
173
|
+
{ path: '/', component: Home },
|
|
174
|
+
{ path: '/about', component: About },
|
|
175
175
|
]
|
|
176
|
-
const router = createRouter({ routes, url:
|
|
176
|
+
const router = createRouter({ routes, url: '/about' })
|
|
177
177
|
setActiveRouter(router as RouterInstance)
|
|
178
|
-
const isActive = useIsActive(
|
|
178
|
+
const isActive = useIsActive('/')
|
|
179
179
|
// Root path in partial mode should only match "/"
|
|
180
180
|
expect(isActive()).toBe(false)
|
|
181
181
|
})
|
|
182
182
|
|
|
183
|
-
test(
|
|
184
|
-
const routes: RouteRecord[] = [{ path:
|
|
185
|
-
const router = createRouter({ routes, url:
|
|
183
|
+
test('param pattern: /user/:id matches /user/42 in exact mode', () => {
|
|
184
|
+
const routes: RouteRecord[] = [{ path: '/user/:id', component: User }]
|
|
185
|
+
const router = createRouter({ routes, url: '/user/42' })
|
|
186
186
|
setActiveRouter(router as RouterInstance)
|
|
187
|
-
const isActive = useIsActive(
|
|
187
|
+
const isActive = useIsActive('/user/:id', true)
|
|
188
188
|
expect(isActive()).toBe(true)
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
test(
|
|
191
|
+
test('param pattern: /user/:id matches /user/42 in partial mode', () => {
|
|
192
192
|
const routes: RouteRecord[] = [
|
|
193
193
|
{
|
|
194
|
-
path:
|
|
194
|
+
path: '/user/:id',
|
|
195
195
|
component: User,
|
|
196
|
-
children: [{ path:
|
|
196
|
+
children: [{ path: 'posts', component: About }],
|
|
197
197
|
},
|
|
198
198
|
]
|
|
199
|
-
const router = createRouter({ routes, url:
|
|
199
|
+
const router = createRouter({ routes, url: '/user/42/posts' })
|
|
200
200
|
setActiveRouter(router as RouterInstance)
|
|
201
|
-
const isActive = useIsActive(
|
|
201
|
+
const isActive = useIsActive('/user/:id')
|
|
202
202
|
expect(isActive()).toBe(true)
|
|
203
203
|
})
|
|
204
204
|
|
|
205
|
-
test(
|
|
206
|
-
const routes: RouteRecord[] = [{ path:
|
|
207
|
-
const router = createRouter({ routes, url:
|
|
205
|
+
test('exact match with wrong segment count returns false', () => {
|
|
206
|
+
const routes: RouteRecord[] = [{ path: '/a/b/c', component: Home }]
|
|
207
|
+
const router = createRouter({ routes, url: '/a/b/c' })
|
|
208
208
|
setActiveRouter(router as RouterInstance)
|
|
209
|
-
const isActive = useIsActive(
|
|
209
|
+
const isActive = useIsActive('/a/b', true)
|
|
210
210
|
expect(isActive()).toBe(false)
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
-
test(
|
|
214
|
-
const routes: RouteRecord[] = [{ path:
|
|
215
|
-
const router = createRouter({ routes, url:
|
|
213
|
+
test('partial match with more pattern segments than current returns false', () => {
|
|
214
|
+
const routes: RouteRecord[] = [{ path: '/a', component: Home }]
|
|
215
|
+
const router = createRouter({ routes, url: '/a' })
|
|
216
216
|
setActiveRouter(router as RouterInstance)
|
|
217
|
-
const isActive = useIsActive(
|
|
217
|
+
const isActive = useIsActive('/a/b/c')
|
|
218
218
|
expect(isActive()).toBe(false)
|
|
219
219
|
})
|
|
220
220
|
})
|
|
221
221
|
|
|
222
222
|
// ─── useSearchParams — edge cases ────────────────────────────────────────────
|
|
223
223
|
|
|
224
|
-
describe(
|
|
224
|
+
describe('useSearchParams — edge cases', () => {
|
|
225
225
|
beforeEach(() => {
|
|
226
226
|
setActiveRouter(null)
|
|
227
227
|
})
|
|
228
228
|
|
|
229
|
-
test(
|
|
230
|
-
expect(() => useSearchParams()).toThrow(
|
|
229
|
+
test('throws when no router installed', () => {
|
|
230
|
+
expect(() => useSearchParams()).toThrow('[pyreon-router] No router installed')
|
|
231
231
|
})
|
|
232
232
|
|
|
233
|
-
test(
|
|
234
|
-
const routes: RouteRecord[] = [{ path:
|
|
235
|
-
const router = createRouter({ routes, url:
|
|
233
|
+
test('returns query params from current route', () => {
|
|
234
|
+
const routes: RouteRecord[] = [{ path: '/search', component: Home }]
|
|
235
|
+
const router = createRouter({ routes, url: '/search?q=hello&page=1' })
|
|
236
236
|
setActiveRouter(router as RouterInstance)
|
|
237
237
|
const [get] = useSearchParams()
|
|
238
|
-
expect(get().q).toBe(
|
|
239
|
-
expect(get().page).toBe(
|
|
238
|
+
expect(get().q).toBe('hello')
|
|
239
|
+
expect(get().page).toBe('1')
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
-
test(
|
|
243
|
-
const routes: RouteRecord[] = [{ path:
|
|
244
|
-
const router = createRouter({ routes, url:
|
|
242
|
+
test('merges defaults with route query', () => {
|
|
243
|
+
const routes: RouteRecord[] = [{ path: '/search', component: Home }]
|
|
244
|
+
const router = createRouter({ routes, url: '/search?q=hello' })
|
|
245
245
|
setActiveRouter(router as RouterInstance)
|
|
246
|
-
const [get] = useSearchParams({ q:
|
|
247
|
-
expect(get().q).toBe(
|
|
248
|
-
expect(get().page).toBe(
|
|
249
|
-
expect(get().sort).toBe(
|
|
246
|
+
const [get] = useSearchParams({ q: '', page: '1', sort: 'name' })
|
|
247
|
+
expect(get().q).toBe('hello') // from URL, overrides default
|
|
248
|
+
expect(get().page).toBe('1') // from default
|
|
249
|
+
expect(get().sort).toBe('name') // from default
|
|
250
250
|
})
|
|
251
251
|
|
|
252
|
-
test(
|
|
253
|
-
const routes: RouteRecord[] = [{ path:
|
|
254
|
-
const router = createRouter({ routes, url:
|
|
252
|
+
test('set navigates with merged query', async () => {
|
|
253
|
+
const routes: RouteRecord[] = [{ path: '/search', component: Home }]
|
|
254
|
+
const router = createRouter({ routes, url: '/search?q=hello' })
|
|
255
255
|
setActiveRouter(router as RouterInstance)
|
|
256
|
-
const [, set] = useSearchParams({ q:
|
|
256
|
+
const [, set] = useSearchParams({ q: '', page: '1' })
|
|
257
257
|
|
|
258
|
-
await set({ page:
|
|
258
|
+
await set({ page: '2' })
|
|
259
259
|
// Router should navigate — check that the route updated
|
|
260
260
|
const route = router.currentRoute()
|
|
261
|
-
expect(route.query.page).toBe(
|
|
262
|
-
expect(route.query.q).toBe(
|
|
261
|
+
expect(route.query.page).toBe('2')
|
|
262
|
+
expect(route.query.q).toBe('hello')
|
|
263
263
|
})
|
|
264
264
|
})
|
|
265
265
|
|
|
266
266
|
// ─── Router — trailing slash normalization ───────────────────────────────────
|
|
267
267
|
|
|
268
|
-
describe(
|
|
268
|
+
describe('router — trailing slash normalization', () => {
|
|
269
269
|
const routes: RouteRecord[] = [
|
|
270
|
-
{ path:
|
|
271
|
-
{ path:
|
|
270
|
+
{ path: '/', component: Home },
|
|
271
|
+
{ path: '/about', component: About },
|
|
272
272
|
]
|
|
273
273
|
|
|
274
|
-
test(
|
|
275
|
-
const router = createRouter({ routes, url:
|
|
276
|
-
expect(router.currentRoute().path).toBe(
|
|
274
|
+
test('strip mode (default) removes trailing slashes', () => {
|
|
275
|
+
const router = createRouter({ routes, url: '/about/' })
|
|
276
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
277
277
|
})
|
|
278
278
|
|
|
279
|
-
test(
|
|
280
|
-
const router = createRouter({ routes, url:
|
|
281
|
-
expect(router.currentRoute().path).toBe(
|
|
279
|
+
test('add mode ensures trailing slashes', () => {
|
|
280
|
+
const router = createRouter({ routes, url: '/about', trailingSlash: 'add' })
|
|
281
|
+
expect(router.currentRoute().path).toBe('/about/')
|
|
282
282
|
})
|
|
283
283
|
|
|
284
|
-
test(
|
|
285
|
-
const router = createRouter({ routes, url:
|
|
286
|
-
expect(router.currentRoute().path).toBe(
|
|
284
|
+
test('ignore mode does not modify path', () => {
|
|
285
|
+
const router = createRouter({ routes, url: '/about/', trailingSlash: 'ignore' })
|
|
286
|
+
expect(router.currentRoute().path).toBe('/about/')
|
|
287
287
|
})
|
|
288
288
|
|
|
289
|
-
test(
|
|
290
|
-
const router = createRouter({ routes, url:
|
|
291
|
-
expect(router.currentRoute().path).toBe(
|
|
289
|
+
test('root path is not modified by strip mode', () => {
|
|
290
|
+
const router = createRouter({ routes, url: '/', trailingSlash: 'strip' })
|
|
291
|
+
expect(router.currentRoute().path).toBe('/')
|
|
292
292
|
})
|
|
293
293
|
|
|
294
|
-
test(
|
|
295
|
-
const router = createRouter({ routes, url:
|
|
296
|
-
await router.push(
|
|
297
|
-
expect(router.currentRoute().path).toBe(
|
|
298
|
-
expect(router.currentRoute().query.q).toBe(
|
|
294
|
+
test('strip mode handles path with query string', async () => {
|
|
295
|
+
const router = createRouter({ routes, url: '/' })
|
|
296
|
+
await router.push('/about/?q=1')
|
|
297
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
298
|
+
expect(router.currentRoute().query.q).toBe('1')
|
|
299
299
|
})
|
|
300
300
|
})
|
|
301
301
|
|
|
302
302
|
// ─── Router — onError handler ────────────────────────────────────────────────
|
|
303
303
|
|
|
304
|
-
describe(
|
|
305
|
-
test(
|
|
304
|
+
describe('router — onError handler', () => {
|
|
305
|
+
test('onError receives error from failed loader', async () => {
|
|
306
306
|
const errors: unknown[] = []
|
|
307
307
|
const routes: RouteRecord[] = [
|
|
308
|
-
{ path:
|
|
308
|
+
{ path: '/', component: Home },
|
|
309
309
|
{
|
|
310
|
-
path:
|
|
310
|
+
path: '/fail',
|
|
311
311
|
component: About,
|
|
312
312
|
loader: async () => {
|
|
313
|
-
throw new Error(
|
|
313
|
+
throw new Error('loader-error')
|
|
314
314
|
},
|
|
315
315
|
},
|
|
316
316
|
]
|
|
317
317
|
const router = createRouter({
|
|
318
318
|
routes,
|
|
319
|
-
url:
|
|
319
|
+
url: '/',
|
|
320
320
|
onError: (err) => {
|
|
321
321
|
errors.push(err)
|
|
322
322
|
return undefined
|
|
323
323
|
},
|
|
324
324
|
})
|
|
325
325
|
|
|
326
|
-
await router.push(
|
|
326
|
+
await router.push('/fail')
|
|
327
327
|
expect(errors.length).toBe(1)
|
|
328
|
-
expect((errors[0] as Error).message).toBe(
|
|
329
|
-
expect(router.currentRoute().path).toBe(
|
|
328
|
+
expect((errors[0] as Error).message).toBe('loader-error')
|
|
329
|
+
expect(router.currentRoute().path).toBe('/fail')
|
|
330
330
|
})
|
|
331
331
|
|
|
332
|
-
test(
|
|
332
|
+
test('onError returning false cancels navigation', async () => {
|
|
333
333
|
const routes: RouteRecord[] = [
|
|
334
|
-
{ path:
|
|
334
|
+
{ path: '/', component: Home },
|
|
335
335
|
{
|
|
336
|
-
path:
|
|
336
|
+
path: '/fail',
|
|
337
337
|
component: About,
|
|
338
338
|
loader: async () => {
|
|
339
|
-
throw new Error(
|
|
339
|
+
throw new Error('fail')
|
|
340
340
|
},
|
|
341
341
|
},
|
|
342
342
|
]
|
|
343
343
|
const router = createRouter({
|
|
344
344
|
routes,
|
|
345
|
-
url:
|
|
345
|
+
url: '/',
|
|
346
346
|
onError: () => false,
|
|
347
347
|
})
|
|
348
348
|
|
|
349
|
-
await router.push(
|
|
350
|
-
expect(router.currentRoute().path).toBe(
|
|
349
|
+
await router.push('/fail')
|
|
350
|
+
expect(router.currentRoute().path).toBe('/')
|
|
351
351
|
})
|
|
352
352
|
})
|
|
353
353
|
|
|
354
354
|
// ─── Router — destroy ────────────────────────────────────────────────────────
|
|
355
355
|
|
|
356
|
-
describe(
|
|
357
|
-
test(
|
|
358
|
-
const routes: RouteRecord[] = [{ path:
|
|
359
|
-
const router = createRouter({ routes, url:
|
|
356
|
+
describe('router — destroy', () => {
|
|
357
|
+
test('destroy clears guards, hooks, caches, and blockers', () => {
|
|
358
|
+
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
359
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
360
360
|
router.beforeEach(() => true)
|
|
361
361
|
router.afterEach(() => {})
|
|
362
362
|
router._blockers.add(() => false)
|
|
363
|
-
router._loaderData.set(routes[0] as RouteRecord,
|
|
363
|
+
router._loaderData.set(routes[0] as RouteRecord, 'data')
|
|
364
364
|
|
|
365
365
|
router.destroy()
|
|
366
366
|
|
|
@@ -369,9 +369,9 @@ describe("router — destroy", () => {
|
|
|
369
369
|
expect(router._abortController).toBeNull()
|
|
370
370
|
})
|
|
371
371
|
|
|
372
|
-
test(
|
|
373
|
-
const routes: RouteRecord[] = [{ path:
|
|
374
|
-
const router = createRouter({ routes, url:
|
|
372
|
+
test('destroy is idempotent (safe to call twice)', () => {
|
|
373
|
+
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
374
|
+
const router = createRouter({ routes, url: '/' })
|
|
375
375
|
expect(() => {
|
|
376
376
|
router.destroy()
|
|
377
377
|
router.destroy()
|
|
@@ -381,114 +381,114 @@ describe("router — destroy", () => {
|
|
|
381
381
|
|
|
382
382
|
// ─── Router — isReady ────────────────────────────────────────────────────────
|
|
383
383
|
|
|
384
|
-
describe(
|
|
385
|
-
test(
|
|
386
|
-
const routes: RouteRecord[] = [{ path:
|
|
387
|
-
const router = createRouter({ routes, url:
|
|
384
|
+
describe('router — isReady', () => {
|
|
385
|
+
test('isReady resolves after initial navigation', async () => {
|
|
386
|
+
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
387
|
+
const router = createRouter({ routes, url: '/' })
|
|
388
388
|
await router.isReady()
|
|
389
389
|
// Should not hang
|
|
390
|
-
expect(router.currentRoute().path).toBe(
|
|
390
|
+
expect(router.currentRoute().path).toBe('/')
|
|
391
391
|
})
|
|
392
392
|
})
|
|
393
393
|
|
|
394
394
|
// ─── Router — relative path navigation ───────────────────────────────────────
|
|
395
395
|
|
|
396
|
-
describe(
|
|
396
|
+
describe('router — relative path navigation', () => {
|
|
397
397
|
const routes: RouteRecord[] = [
|
|
398
|
-
{ path:
|
|
399
|
-
{ path:
|
|
398
|
+
{ path: '/', component: Home },
|
|
399
|
+
{ path: '/user/:id', component: User },
|
|
400
400
|
{
|
|
401
|
-
path:
|
|
401
|
+
path: '/admin',
|
|
402
402
|
component: Home,
|
|
403
403
|
children: [
|
|
404
|
-
{ path:
|
|
405
|
-
{ path:
|
|
404
|
+
{ path: 'users', component: About },
|
|
405
|
+
{ path: 'settings', component: User },
|
|
406
406
|
],
|
|
407
407
|
},
|
|
408
408
|
]
|
|
409
409
|
|
|
410
|
-
test(
|
|
411
|
-
const router = createRouter({ routes, url:
|
|
412
|
-
await router.push(
|
|
413
|
-
expect(router.currentRoute().path).toBe(
|
|
410
|
+
test('relative path ./sibling navigates correctly', async () => {
|
|
411
|
+
const router = createRouter({ routes, url: '/admin/users' })
|
|
412
|
+
await router.push('./settings')
|
|
413
|
+
expect(router.currentRoute().path).toBe('/admin/settings')
|
|
414
414
|
})
|
|
415
415
|
|
|
416
|
-
test(
|
|
417
|
-
const router = createRouter({ routes, url:
|
|
418
|
-
await router.push(
|
|
419
|
-
expect(router.currentRoute().path).toBe(
|
|
416
|
+
test('relative path ../up navigates correctly', async () => {
|
|
417
|
+
const router = createRouter({ routes, url: '/admin/users' })
|
|
418
|
+
await router.push('../')
|
|
419
|
+
expect(router.currentRoute().path).toBe('/')
|
|
420
420
|
})
|
|
421
421
|
|
|
422
|
-
test(
|
|
423
|
-
const router = createRouter({ routes, url:
|
|
424
|
-
await router.push(
|
|
425
|
-
expect(router.currentRoute().path).toBe(
|
|
422
|
+
test('absolute path is not modified', async () => {
|
|
423
|
+
const router = createRouter({ routes, url: '/admin/users' })
|
|
424
|
+
await router.push('/user/42')
|
|
425
|
+
expect(router.currentRoute().path).toBe('/user/42')
|
|
426
426
|
})
|
|
427
427
|
})
|
|
428
428
|
|
|
429
429
|
// ─── Router — replace with named route ───────────────────────────────────────
|
|
430
430
|
|
|
431
|
-
describe(
|
|
432
|
-
test(
|
|
431
|
+
describe('router — replace with named route', () => {
|
|
432
|
+
test('replace with named route navigates correctly', async () => {
|
|
433
433
|
const routes: RouteRecord[] = [
|
|
434
|
-
{ path:
|
|
435
|
-
{ path:
|
|
434
|
+
{ path: '/', component: Home, name: 'home' },
|
|
435
|
+
{ path: '/user/:id', component: User, name: 'user' },
|
|
436
436
|
]
|
|
437
|
-
const router = createRouter({ routes, url:
|
|
438
|
-
await router.replace({ name:
|
|
439
|
-
expect(router.currentRoute().path).toBe(
|
|
437
|
+
const router = createRouter({ routes, url: '/' })
|
|
438
|
+
await router.replace({ name: 'user', params: { id: '42' } })
|
|
439
|
+
expect(router.currentRoute().path).toBe('/user/42')
|
|
440
440
|
})
|
|
441
441
|
|
|
442
|
-
test(
|
|
443
|
-
const routes: RouteRecord[] = [{ path:
|
|
444
|
-
const router = createRouter({ routes, url:
|
|
445
|
-
await router.replace({ name:
|
|
446
|
-
expect(router.currentRoute().path).toBe(
|
|
442
|
+
test('replace with unknown named route falls back to /', async () => {
|
|
443
|
+
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
444
|
+
const router = createRouter({ routes, url: '/' })
|
|
445
|
+
await router.replace({ name: 'nonexistent' })
|
|
446
|
+
expect(router.currentRoute().path).toBe('/')
|
|
447
447
|
})
|
|
448
448
|
})
|
|
449
449
|
|
|
450
450
|
// ─── Router — sanitize unsafe URLs ───────────────────────────────────────────
|
|
451
451
|
|
|
452
|
-
describe(
|
|
452
|
+
describe('router — URL sanitization', () => {
|
|
453
453
|
const routes: RouteRecord[] = [
|
|
454
|
-
{ path:
|
|
455
|
-
{ path:
|
|
454
|
+
{ path: '/', component: Home },
|
|
455
|
+
{ path: '/about', component: About },
|
|
456
456
|
]
|
|
457
457
|
|
|
458
|
-
test(
|
|
459
|
-
const router = createRouter({ routes, url:
|
|
460
|
-
await router.push(
|
|
461
|
-
expect(router.currentRoute().path).toBe(
|
|
458
|
+
test('blocks vbscript: URI', async () => {
|
|
459
|
+
const router = createRouter({ routes, url: '/' })
|
|
460
|
+
await router.push('vbscript:alert(1)')
|
|
461
|
+
expect(router.currentRoute().path).toBe('/')
|
|
462
462
|
})
|
|
463
463
|
|
|
464
|
-
test(
|
|
465
|
-
const router = createRouter({ routes, url:
|
|
466
|
-
await router.push(
|
|
467
|
-
expect(router.currentRoute().path).toBe(
|
|
464
|
+
test('blocks absolute URLs (http)', async () => {
|
|
465
|
+
const router = createRouter({ routes, url: '/' })
|
|
466
|
+
await router.push('http://evil.com')
|
|
467
|
+
expect(router.currentRoute().path).toBe('/')
|
|
468
468
|
})
|
|
469
469
|
|
|
470
|
-
test(
|
|
471
|
-
const router = createRouter({ routes, url:
|
|
472
|
-
await router.push(
|
|
473
|
-
expect(router.currentRoute().path).toBe(
|
|
470
|
+
test('blocks absolute URLs (https)', async () => {
|
|
471
|
+
const router = createRouter({ routes, url: '/' })
|
|
472
|
+
await router.push('https://evil.com')
|
|
473
|
+
expect(router.currentRoute().path).toBe('/')
|
|
474
474
|
})
|
|
475
475
|
|
|
476
|
-
test(
|
|
477
|
-
const router = createRouter({ routes, url:
|
|
478
|
-
await router.push(
|
|
479
|
-
expect(router.currentRoute().path).toBe(
|
|
476
|
+
test('blocks protocol-relative URLs', async () => {
|
|
477
|
+
const router = createRouter({ routes, url: '/' })
|
|
478
|
+
await router.push('//evil.com')
|
|
479
|
+
expect(router.currentRoute().path).toBe('/')
|
|
480
480
|
})
|
|
481
481
|
})
|
|
482
482
|
|
|
483
483
|
// ─── Router — staleWhileRevalidate ───────────────────────────────────────────
|
|
484
484
|
|
|
485
|
-
describe(
|
|
486
|
-
test(
|
|
485
|
+
describe('router — staleWhileRevalidate', () => {
|
|
486
|
+
test('serves stale data immediately, revalidates in background', async () => {
|
|
487
487
|
let loaderCallCount = 0
|
|
488
488
|
const routes: RouteRecord[] = [
|
|
489
|
-
{ path:
|
|
489
|
+
{ path: '/', component: Home },
|
|
490
490
|
{
|
|
491
|
-
path:
|
|
491
|
+
path: '/data',
|
|
492
492
|
component: About,
|
|
493
493
|
staleWhileRevalidate: true,
|
|
494
494
|
loader: async () => {
|
|
@@ -497,16 +497,16 @@ describe("router — staleWhileRevalidate", () => {
|
|
|
497
497
|
},
|
|
498
498
|
},
|
|
499
499
|
]
|
|
500
|
-
const router = createRouter({ routes, url:
|
|
500
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
501
501
|
|
|
502
502
|
// First navigation — loader runs as blocking
|
|
503
|
-
await router.push(
|
|
503
|
+
await router.push('/data')
|
|
504
504
|
expect(loaderCallCount).toBe(1)
|
|
505
|
-
expect(router._loaderData.get(routes[1] as RouteRecord)).toBe(
|
|
505
|
+
expect(router._loaderData.get(routes[1] as RouteRecord)).toBe('data-v1')
|
|
506
506
|
|
|
507
507
|
// Navigate away and back — should show stale data and revalidate
|
|
508
|
-
await router.push(
|
|
509
|
-
await router.push(
|
|
508
|
+
await router.push('/')
|
|
509
|
+
await router.push('/data')
|
|
510
510
|
|
|
511
511
|
// Give background revalidation time
|
|
512
512
|
await new Promise<void>((r) => setTimeout(r, 50))
|