@pyreon/router 0.24.4 → 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
package/src/tests/loader.test.ts
DELETED
|
@@ -1,1024 +0,0 @@
|
|
|
1
|
-
import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from '../loader'
|
|
2
|
-
import { createRouter, setActiveRouter, useIsActive, useSearchParams } from '../router'
|
|
3
|
-
import { lazy } from '../types'
|
|
4
|
-
import type { RouteRecord, RouterInstance } from '../types'
|
|
5
|
-
|
|
6
|
-
const Home = () => null
|
|
7
|
-
const About = () => null
|
|
8
|
-
const User = () => null
|
|
9
|
-
|
|
10
|
-
// ─── serializeLoaderData / hydrateLoaderData round-trip edge cases ────────────
|
|
11
|
-
|
|
12
|
-
describe('loader data serialization — edge cases', () => {
|
|
13
|
-
test('serializes multiple route loaders', async () => {
|
|
14
|
-
const routes: RouteRecord[] = [
|
|
15
|
-
{
|
|
16
|
-
path: '/admin',
|
|
17
|
-
component: Home,
|
|
18
|
-
loader: async () => 'admin-data',
|
|
19
|
-
children: [
|
|
20
|
-
{
|
|
21
|
-
path: 'users',
|
|
22
|
-
component: About,
|
|
23
|
-
loader: async () => 'users-data',
|
|
24
|
-
},
|
|
25
|
-
],
|
|
26
|
-
},
|
|
27
|
-
]
|
|
28
|
-
const router = createRouter({ routes, url: '/admin/users' }) as RouterInstance
|
|
29
|
-
await prefetchLoaderData(router, '/admin/users')
|
|
30
|
-
|
|
31
|
-
const serialized = serializeLoaderData(router)
|
|
32
|
-
expect(serialized['/admin']).toBe('admin-data')
|
|
33
|
-
expect(serialized.users).toBe('users-data')
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test('hydrate ignores paths not in current route matched', () => {
|
|
37
|
-
const routes: RouteRecord[] = [
|
|
38
|
-
{ path: '/', component: Home },
|
|
39
|
-
{ path: '/page', component: About, loader: async () => [] },
|
|
40
|
-
]
|
|
41
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
42
|
-
// Hydrate with data for a path that is NOT currently matched
|
|
43
|
-
hydrateLoaderData(router, { '/page': 'should-be-ignored' })
|
|
44
|
-
expect(router._loaderData.size).toBe(0)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('hydrate with non-object values is no-op', () => {
|
|
48
|
-
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
49
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
50
|
-
|
|
51
|
-
// These should not throw
|
|
52
|
-
hydrateLoaderData(router, null as unknown as Record<string, unknown>)
|
|
53
|
-
hydrateLoaderData(router, undefined as unknown as Record<string, unknown>)
|
|
54
|
-
hydrateLoaderData(router, 42 as unknown as Record<string, unknown>)
|
|
55
|
-
expect(router._loaderData.size).toBe(0)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('round-trip with complex data types', async () => {
|
|
59
|
-
const complexData = {
|
|
60
|
-
items: [
|
|
61
|
-
{ id: 1, name: 'Item 1' },
|
|
62
|
-
{ id: 2, name: 'Item 2' },
|
|
63
|
-
],
|
|
64
|
-
meta: { total: 2, page: 1 },
|
|
65
|
-
nested: { deep: { value: true } },
|
|
66
|
-
}
|
|
67
|
-
const routes: RouteRecord[] = [
|
|
68
|
-
{ path: '/data', component: Home, loader: async () => complexData },
|
|
69
|
-
]
|
|
70
|
-
const ssrRouter = createRouter({ routes, url: '/data' }) as RouterInstance
|
|
71
|
-
await prefetchLoaderData(ssrRouter, '/data')
|
|
72
|
-
|
|
73
|
-
const serialized = serializeLoaderData(ssrRouter)
|
|
74
|
-
const clientRouter = createRouter({ routes, url: '/data' }) as RouterInstance
|
|
75
|
-
hydrateLoaderData(clientRouter, serialized)
|
|
76
|
-
|
|
77
|
-
const values = Array.from(clientRouter._loaderData.values())
|
|
78
|
-
expect(values[0]).toEqual(complexData)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('prefetchLoaderData does NOT clobber router._abortController', async () => {
|
|
82
|
-
// Regression: `prefetchLoaderData` used to overwrite
|
|
83
|
-
// `router._abortController` with its own fresh controller. Hovering
|
|
84
|
-
// a <Link> during an in-flight navigation destroyed the nav's
|
|
85
|
-
// abort capability — subsequent navigations couldn't cancel the
|
|
86
|
-
// first one. Fix: prefetch uses a LOCAL controller.
|
|
87
|
-
const routes: RouteRecord[] = [
|
|
88
|
-
{ path: '/data', component: Home, loader: async () => 'ok' },
|
|
89
|
-
]
|
|
90
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
91
|
-
const navController = new AbortController()
|
|
92
|
-
router._abortController = navController
|
|
93
|
-
await prefetchLoaderData(router, '/data')
|
|
94
|
-
// Prefetch finished; nav's controller must be untouched.
|
|
95
|
-
expect(router._abortController).toBe(navController)
|
|
96
|
-
expect(navController.signal.aborted).toBe(false)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('prefetchLoaderData passes AbortSignal to loaders', async () => {
|
|
100
|
-
let receivedSignal: AbortSignal | undefined
|
|
101
|
-
const routes: RouteRecord[] = [
|
|
102
|
-
{
|
|
103
|
-
path: '/data',
|
|
104
|
-
component: Home,
|
|
105
|
-
loader: async ({ signal }) => {
|
|
106
|
-
receivedSignal = signal
|
|
107
|
-
return 'ok'
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
]
|
|
111
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
112
|
-
await prefetchLoaderData(router, '/data')
|
|
113
|
-
expect(receivedSignal).toBeDefined()
|
|
114
|
-
expect(receivedSignal).toBeInstanceOf(AbortSignal)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('prefetchLoaderData skips routes without loaders', async () => {
|
|
118
|
-
const routes: RouteRecord[] = [
|
|
119
|
-
{
|
|
120
|
-
path: '/admin',
|
|
121
|
-
component: Home,
|
|
122
|
-
children: [
|
|
123
|
-
{ path: 'users', component: About }, // no loader
|
|
124
|
-
],
|
|
125
|
-
},
|
|
126
|
-
]
|
|
127
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
128
|
-
await prefetchLoaderData(router, '/admin/users')
|
|
129
|
-
expect(router._loaderData.size).toBe(0)
|
|
130
|
-
})
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
// ─── M2.2 — stringifyLoaderData ────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
describe('stringifyLoaderData (M2.2)', () => {
|
|
136
|
-
// Bisect-load-bearing: revert the replacer (use bare `JSON.stringify(d).replace(/<\//g, '<\\/')`)
|
|
137
|
-
// → the function-strip + circular-error specs fail. The bare-strings-only spec
|
|
138
|
-
// would still pass since JSON.stringify also drops function values for objects.
|
|
139
|
-
|
|
140
|
-
test('strips function values silently', () => {
|
|
141
|
-
const json = stringifyLoaderData({
|
|
142
|
-
'/home': { data: 1, fn: () => {} },
|
|
143
|
-
})
|
|
144
|
-
expect(json).not.toContain('fn')
|
|
145
|
-
expect(json).toContain('"data":1')
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
test('strips symbol values silently', () => {
|
|
149
|
-
const json = stringifyLoaderData({
|
|
150
|
-
'/home': { data: 1, sym: Symbol('x') as unknown as string },
|
|
151
|
-
})
|
|
152
|
-
expect(json).not.toContain('sym')
|
|
153
|
-
expect(json).toContain('"data":1')
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
test('throws Pyreon-prefixed error on circular reference naming the offending key', () => {
|
|
157
|
-
interface Cyclic {
|
|
158
|
-
data: number
|
|
159
|
-
self?: Cyclic
|
|
160
|
-
}
|
|
161
|
-
const cyclic: Cyclic = { data: 1 }
|
|
162
|
-
cyclic.self = cyclic
|
|
163
|
-
expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\[Pyreon\] Loader returned circular reference/)
|
|
164
|
-
// The error names the path: `/posts/1.self` (or similar).
|
|
165
|
-
expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\/posts\/1/)
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('escapes </script> to prevent script-tag escape', () => {
|
|
169
|
-
const json = stringifyLoaderData({
|
|
170
|
-
'/home': { html: '</script><script>alert(1)' },
|
|
171
|
-
})
|
|
172
|
-
expect(json).not.toContain('</script>')
|
|
173
|
-
expect(json).toContain('<\\/script>')
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
test('passes plain data through unchanged', () => {
|
|
177
|
-
const json = stringifyLoaderData({
|
|
178
|
-
'/posts': [{ id: 1, title: 'A' }],
|
|
179
|
-
'/about': { count: 42 },
|
|
180
|
-
})
|
|
181
|
-
expect(JSON.parse(json)).toEqual({
|
|
182
|
-
'/posts': [{ id: 1, title: 'A' }],
|
|
183
|
-
'/about': { count: 42 },
|
|
184
|
-
})
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
test('shared (DAG) references serialize — only true cycles throw', () => {
|
|
188
|
-
// BEHAVIOUR CHANGE (intentional, bug fix). Previously the all-seen
|
|
189
|
-
// WeakSet threw "circular reference" on ANY object visited twice — a
|
|
190
|
-
// shared reference (DAG), not a cycle. That 500'd the SSR response for
|
|
191
|
-
// extremely common loader payloads (an ORM returning the same instance
|
|
192
|
-
// twice). The original code's own comment anticipated this remedy:
|
|
193
|
-
// "if this becomes too aggressive, relax with a post-visit drop
|
|
194
|
-
// instead of WeakSet" — which is exactly what ancestor-path detection
|
|
195
|
-
// does. Strictly more permissive: inputs that throw before now succeed;
|
|
196
|
-
// inputs that worked are unchanged; real cycles still throw.
|
|
197
|
-
const shared = [1, 2, 3]
|
|
198
|
-
const json = stringifyLoaderData({ '/a': shared, '/b': shared })
|
|
199
|
-
expect(JSON.parse(json)).toEqual({ '/a': [1, 2, 3], '/b': [1, 2, 3] })
|
|
200
|
-
|
|
201
|
-
// Canonical real-world shape: one user object referenced twice.
|
|
202
|
-
const user = { id: 7, name: 'Ada' }
|
|
203
|
-
const post = stringifyLoaderData({ '/posts/1': { author: user, lastEditor: user } })
|
|
204
|
-
expect(JSON.parse(post)).toEqual({
|
|
205
|
-
'/posts/1': { author: { id: 7, name: 'Ada' }, lastEditor: { id: 7, name: 'Ada' } },
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
// Diamond: same node reachable via two paths, no cycle.
|
|
209
|
-
const leaf = { v: 1 }
|
|
210
|
-
const diamond = stringifyLoaderData({ '/d': { left: { leaf }, right: { leaf } } })
|
|
211
|
-
expect(JSON.parse(diamond)).toEqual({ '/d': { left: { leaf: { v: 1 } }, right: { leaf: { v: 1 } } } })
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
test('still throws on a true cycle through a shared-looking path', () => {
|
|
215
|
-
// A shared ref that ALSO closes a cycle must still throw.
|
|
216
|
-
interface N {
|
|
217
|
-
id: number
|
|
218
|
-
next?: N
|
|
219
|
-
}
|
|
220
|
-
const a: N = { id: 1 }
|
|
221
|
-
const b: N = { id: 2 }
|
|
222
|
-
a.next = b
|
|
223
|
-
b.next = a // real cycle
|
|
224
|
-
expect(() => stringifyLoaderData({ '/x': { a, b } })).toThrow(/\[Pyreon\] Loader returned circular reference/)
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
test('empty record produces empty object JSON', () => {
|
|
228
|
-
expect(stringifyLoaderData({})).toBe('{}')
|
|
229
|
-
})
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
// ─── useIsActive — edge cases ────────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
describe('useIsActive — edge cases', () => {
|
|
235
|
-
beforeEach(() => {
|
|
236
|
-
setActiveRouter(null)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
test('throws when no router installed', () => {
|
|
240
|
-
expect(() => useIsActive('/')).toThrow('[Pyreon] No router installed')
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
test('exact match for root path', () => {
|
|
244
|
-
const router = createRouter({ routes: [{ path: '/', component: Home }], url: '/' })
|
|
245
|
-
setActiveRouter(router as RouterInstance)
|
|
246
|
-
const isActive = useIsActive('/', true)
|
|
247
|
-
expect(isActive()).toBe(true)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
test('partial match: /admin matches /admin/users', () => {
|
|
251
|
-
const routes: RouteRecord[] = [
|
|
252
|
-
{
|
|
253
|
-
path: '/admin',
|
|
254
|
-
component: Home,
|
|
255
|
-
children: [{ path: 'users', component: About }],
|
|
256
|
-
},
|
|
257
|
-
]
|
|
258
|
-
const router = createRouter({ routes, url: '/admin/users' })
|
|
259
|
-
setActiveRouter(router as RouterInstance)
|
|
260
|
-
const isActive = useIsActive('/admin')
|
|
261
|
-
expect(isActive()).toBe(true)
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
test('partial match: /admin does NOT match /admin-panel', async () => {
|
|
265
|
-
const routes: RouteRecord[] = [
|
|
266
|
-
{ path: '/admin', component: Home },
|
|
267
|
-
{ path: '/admin-panel', component: About },
|
|
268
|
-
]
|
|
269
|
-
const router = createRouter({ routes, url: '/admin-panel' })
|
|
270
|
-
setActiveRouter(router as RouterInstance)
|
|
271
|
-
const isActive = useIsActive('/admin')
|
|
272
|
-
expect(isActive()).toBe(false)
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
test('exact match: /admin does NOT match /admin/users', () => {
|
|
276
|
-
const routes: RouteRecord[] = [
|
|
277
|
-
{
|
|
278
|
-
path: '/admin',
|
|
279
|
-
component: Home,
|
|
280
|
-
children: [{ path: 'users', component: About }],
|
|
281
|
-
},
|
|
282
|
-
]
|
|
283
|
-
const router = createRouter({ routes, url: '/admin/users' })
|
|
284
|
-
setActiveRouter(router as RouterInstance)
|
|
285
|
-
const isActive = useIsActive('/admin', true)
|
|
286
|
-
expect(isActive()).toBe(false)
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
test('root path partial match only matches /', () => {
|
|
290
|
-
const routes: RouteRecord[] = [
|
|
291
|
-
{ path: '/', component: Home },
|
|
292
|
-
{ path: '/about', component: About },
|
|
293
|
-
]
|
|
294
|
-
const router = createRouter({ routes, url: '/about' })
|
|
295
|
-
setActiveRouter(router as RouterInstance)
|
|
296
|
-
const isActive = useIsActive('/')
|
|
297
|
-
// Root path in partial mode should only match "/"
|
|
298
|
-
expect(isActive()).toBe(false)
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
test('param pattern: /user/:id matches /user/42 in exact mode', () => {
|
|
302
|
-
const routes: RouteRecord[] = [{ path: '/user/:id', component: User }]
|
|
303
|
-
const router = createRouter({ routes, url: '/user/42' })
|
|
304
|
-
setActiveRouter(router as RouterInstance)
|
|
305
|
-
const isActive = useIsActive('/user/:id', true)
|
|
306
|
-
expect(isActive()).toBe(true)
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
test('param pattern: /user/:id matches /user/42 in partial mode', () => {
|
|
310
|
-
const routes: RouteRecord[] = [
|
|
311
|
-
{
|
|
312
|
-
path: '/user/:id',
|
|
313
|
-
component: User,
|
|
314
|
-
children: [{ path: 'posts', component: About }],
|
|
315
|
-
},
|
|
316
|
-
]
|
|
317
|
-
const router = createRouter({ routes, url: '/user/42/posts' })
|
|
318
|
-
setActiveRouter(router as RouterInstance)
|
|
319
|
-
const isActive = useIsActive('/user/:id')
|
|
320
|
-
expect(isActive()).toBe(true)
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
test('exact match with wrong segment count returns false', () => {
|
|
324
|
-
const routes: RouteRecord[] = [{ path: '/a/b/c', component: Home }]
|
|
325
|
-
const router = createRouter({ routes, url: '/a/b/c' })
|
|
326
|
-
setActiveRouter(router as RouterInstance)
|
|
327
|
-
const isActive = useIsActive('/a/b', true)
|
|
328
|
-
expect(isActive()).toBe(false)
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
test('partial match with more pattern segments than current returns false', () => {
|
|
332
|
-
const routes: RouteRecord[] = [{ path: '/a', component: Home }]
|
|
333
|
-
const router = createRouter({ routes, url: '/a' })
|
|
334
|
-
setActiveRouter(router as RouterInstance)
|
|
335
|
-
const isActive = useIsActive('/a/b/c')
|
|
336
|
-
expect(isActive()).toBe(false)
|
|
337
|
-
})
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
// ─── useSearchParams — edge cases ────────────────────────────────────────────
|
|
341
|
-
|
|
342
|
-
describe('useSearchParams — edge cases', () => {
|
|
343
|
-
beforeEach(() => {
|
|
344
|
-
setActiveRouter(null)
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
test('throws when no router installed', () => {
|
|
348
|
-
expect(() => useSearchParams()).toThrow('[Pyreon] No router installed')
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
test('returns query params from current route', () => {
|
|
352
|
-
const routes: RouteRecord[] = [{ path: '/search', component: Home }]
|
|
353
|
-
const router = createRouter({ routes, url: '/search?q=hello&page=1' })
|
|
354
|
-
setActiveRouter(router as RouterInstance)
|
|
355
|
-
const [get] = useSearchParams()
|
|
356
|
-
expect(get().q).toBe('hello')
|
|
357
|
-
expect(get().page).toBe('1')
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
test('merges defaults with route query', () => {
|
|
361
|
-
const routes: RouteRecord[] = [{ path: '/search', component: Home }]
|
|
362
|
-
const router = createRouter({ routes, url: '/search?q=hello' })
|
|
363
|
-
setActiveRouter(router as RouterInstance)
|
|
364
|
-
const [get] = useSearchParams({ q: '', page: '1', sort: 'name' })
|
|
365
|
-
expect(get().q).toBe('hello') // from URL, overrides default
|
|
366
|
-
expect(get().page).toBe('1') // from default
|
|
367
|
-
expect(get().sort).toBe('name') // from default
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
test('set navigates with merged query', async () => {
|
|
371
|
-
const routes: RouteRecord[] = [{ path: '/search', component: Home }]
|
|
372
|
-
const router = createRouter({ routes, url: '/search?q=hello' })
|
|
373
|
-
setActiveRouter(router as RouterInstance)
|
|
374
|
-
const [, set] = useSearchParams({ q: '', page: '1' })
|
|
375
|
-
|
|
376
|
-
await set({ page: '2' })
|
|
377
|
-
// Router should navigate — check that the route updated
|
|
378
|
-
const route = router.currentRoute()
|
|
379
|
-
expect(route.query.page).toBe('2')
|
|
380
|
-
expect(route.query.q).toBe('hello')
|
|
381
|
-
})
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
// ─── Router — trailing slash normalization ───────────────────────────────────
|
|
385
|
-
|
|
386
|
-
describe('router — trailing slash normalization', () => {
|
|
387
|
-
const routes: RouteRecord[] = [
|
|
388
|
-
{ path: '/', component: Home },
|
|
389
|
-
{ path: '/about', component: About },
|
|
390
|
-
]
|
|
391
|
-
|
|
392
|
-
test('strip mode (default) removes trailing slashes', () => {
|
|
393
|
-
const router = createRouter({ routes, url: '/about/' })
|
|
394
|
-
expect(router.currentRoute().path).toBe('/about')
|
|
395
|
-
})
|
|
396
|
-
|
|
397
|
-
test('add mode ensures trailing slashes', () => {
|
|
398
|
-
const router = createRouter({ routes, url: '/about', trailingSlash: 'add' })
|
|
399
|
-
expect(router.currentRoute().path).toBe('/about/')
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
test('ignore mode does not modify path', () => {
|
|
403
|
-
const router = createRouter({ routes, url: '/about/', trailingSlash: 'ignore' })
|
|
404
|
-
expect(router.currentRoute().path).toBe('/about/')
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
test('root path is not modified by strip mode', () => {
|
|
408
|
-
const router = createRouter({ routes, url: '/', trailingSlash: 'strip' })
|
|
409
|
-
expect(router.currentRoute().path).toBe('/')
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
test('strip mode handles path with query string', async () => {
|
|
413
|
-
const router = createRouter({ routes, url: '/' })
|
|
414
|
-
await router.push('/about/?q=1')
|
|
415
|
-
expect(router.currentRoute().path).toBe('/about')
|
|
416
|
-
expect(router.currentRoute().query.q).toBe('1')
|
|
417
|
-
})
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
// ─── Router — onError handler ────────────────────────────────────────────────
|
|
421
|
-
|
|
422
|
-
describe('router — onError handler', () => {
|
|
423
|
-
test('onError receives error from failed loader', async () => {
|
|
424
|
-
const errors: unknown[] = []
|
|
425
|
-
const routes: RouteRecord[] = [
|
|
426
|
-
{ path: '/', component: Home },
|
|
427
|
-
{
|
|
428
|
-
path: '/fail',
|
|
429
|
-
component: About,
|
|
430
|
-
loader: async () => {
|
|
431
|
-
throw new Error('loader-error')
|
|
432
|
-
},
|
|
433
|
-
},
|
|
434
|
-
]
|
|
435
|
-
const router = createRouter({
|
|
436
|
-
routes,
|
|
437
|
-
url: '/',
|
|
438
|
-
onError: (err) => {
|
|
439
|
-
errors.push(err)
|
|
440
|
-
return undefined
|
|
441
|
-
},
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
await router.push('/fail')
|
|
445
|
-
expect(errors.length).toBe(1)
|
|
446
|
-
expect((errors[0] as Error).message).toBe('loader-error')
|
|
447
|
-
expect(router.currentRoute().path).toBe('/fail')
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
test('onError returning false cancels navigation', async () => {
|
|
451
|
-
const routes: RouteRecord[] = [
|
|
452
|
-
{ path: '/', component: Home },
|
|
453
|
-
{
|
|
454
|
-
path: '/fail',
|
|
455
|
-
component: About,
|
|
456
|
-
loader: async () => {
|
|
457
|
-
throw new Error('fail')
|
|
458
|
-
},
|
|
459
|
-
},
|
|
460
|
-
]
|
|
461
|
-
const router = createRouter({
|
|
462
|
-
routes,
|
|
463
|
-
url: '/',
|
|
464
|
-
onError: () => false,
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
await router.push('/fail')
|
|
468
|
-
expect(router.currentRoute().path).toBe('/')
|
|
469
|
-
})
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
// ─── Router — destroy ────────────────────────────────────────────────────────
|
|
473
|
-
|
|
474
|
-
describe('router — destroy', () => {
|
|
475
|
-
test('destroy clears guards, hooks, caches, and blockers', () => {
|
|
476
|
-
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
477
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
478
|
-
router.beforeEach(() => true)
|
|
479
|
-
router.afterEach(() => {})
|
|
480
|
-
router._blockers.add(() => false)
|
|
481
|
-
router._loaderData.set(routes[0] as RouteRecord, 'data')
|
|
482
|
-
|
|
483
|
-
router.destroy()
|
|
484
|
-
|
|
485
|
-
expect(router._blockers.size).toBe(0)
|
|
486
|
-
expect(router._loaderData.size).toBe(0)
|
|
487
|
-
expect(router._abortController).toBeNull()
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
test('destroy is idempotent (safe to call twice)', () => {
|
|
491
|
-
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
492
|
-
const router = createRouter({ routes, url: '/' })
|
|
493
|
-
expect(() => {
|
|
494
|
-
router.destroy()
|
|
495
|
-
router.destroy()
|
|
496
|
-
}).not.toThrow()
|
|
497
|
-
})
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
// ─── Router — isReady ────────────────────────────────────────────────────────
|
|
501
|
-
|
|
502
|
-
describe('router — isReady', () => {
|
|
503
|
-
test('isReady resolves after initial navigation', async () => {
|
|
504
|
-
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
505
|
-
const router = createRouter({ routes, url: '/' })
|
|
506
|
-
await router.isReady()
|
|
507
|
-
// Should not hang
|
|
508
|
-
expect(router.currentRoute().path).toBe('/')
|
|
509
|
-
})
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
// ─── Router — relative path navigation ───────────────────────────────────────
|
|
513
|
-
|
|
514
|
-
describe('router — relative path navigation', () => {
|
|
515
|
-
const routes: RouteRecord[] = [
|
|
516
|
-
{ path: '/', component: Home },
|
|
517
|
-
{ path: '/user/:id', component: User },
|
|
518
|
-
{
|
|
519
|
-
path: '/admin',
|
|
520
|
-
component: Home,
|
|
521
|
-
children: [
|
|
522
|
-
{ path: 'users', component: About },
|
|
523
|
-
{ path: 'settings', component: User },
|
|
524
|
-
],
|
|
525
|
-
},
|
|
526
|
-
]
|
|
527
|
-
|
|
528
|
-
test('relative path ./sibling navigates correctly', async () => {
|
|
529
|
-
const router = createRouter({ routes, url: '/admin/users' })
|
|
530
|
-
await router.push('./settings')
|
|
531
|
-
expect(router.currentRoute().path).toBe('/admin/settings')
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
test('relative path ../up navigates correctly', async () => {
|
|
535
|
-
const router = createRouter({ routes, url: '/admin/users' })
|
|
536
|
-
await router.push('../')
|
|
537
|
-
expect(router.currentRoute().path).toBe('/')
|
|
538
|
-
})
|
|
539
|
-
|
|
540
|
-
test('absolute path is not modified', async () => {
|
|
541
|
-
const router = createRouter({ routes, url: '/admin/users' })
|
|
542
|
-
await router.push('/user/42')
|
|
543
|
-
expect(router.currentRoute().path).toBe('/user/42')
|
|
544
|
-
})
|
|
545
|
-
})
|
|
546
|
-
|
|
547
|
-
// ─── Router — replace with named route ───────────────────────────────────────
|
|
548
|
-
|
|
549
|
-
describe('router — replace with named route', () => {
|
|
550
|
-
test('replace with named route navigates correctly', async () => {
|
|
551
|
-
const routes: RouteRecord[] = [
|
|
552
|
-
{ path: '/', component: Home, name: 'home' },
|
|
553
|
-
{ path: '/user/:id', component: User, name: 'user' },
|
|
554
|
-
]
|
|
555
|
-
const router = createRouter({ routes, url: '/' })
|
|
556
|
-
await router.replace({ name: 'user', params: { id: '42' } })
|
|
557
|
-
expect(router.currentRoute().path).toBe('/user/42')
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
test('replace with unknown named route falls back to /', async () => {
|
|
561
|
-
const routes: RouteRecord[] = [{ path: '/', component: Home }]
|
|
562
|
-
const router = createRouter({ routes, url: '/' })
|
|
563
|
-
await router.replace({ name: 'nonexistent' })
|
|
564
|
-
expect(router.currentRoute().path).toBe('/')
|
|
565
|
-
})
|
|
566
|
-
})
|
|
567
|
-
|
|
568
|
-
// ─── Router — sanitize unsafe URLs ───────────────────────────────────────────
|
|
569
|
-
|
|
570
|
-
describe('router — URL sanitization', () => {
|
|
571
|
-
const routes: RouteRecord[] = [
|
|
572
|
-
{ path: '/', component: Home },
|
|
573
|
-
{ path: '/about', component: About },
|
|
574
|
-
]
|
|
575
|
-
|
|
576
|
-
test('blocks vbscript: URI', async () => {
|
|
577
|
-
const router = createRouter({ routes, url: '/' })
|
|
578
|
-
await router.push('vbscript:alert(1)')
|
|
579
|
-
expect(router.currentRoute().path).toBe('/')
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
test('blocks absolute URLs (http)', async () => {
|
|
583
|
-
const router = createRouter({ routes, url: '/' })
|
|
584
|
-
await router.push('http://evil.com')
|
|
585
|
-
expect(router.currentRoute().path).toBe('/')
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
test('blocks absolute URLs (https)', async () => {
|
|
589
|
-
const router = createRouter({ routes, url: '/' })
|
|
590
|
-
await router.push('https://evil.com')
|
|
591
|
-
expect(router.currentRoute().path).toBe('/')
|
|
592
|
-
})
|
|
593
|
-
|
|
594
|
-
test('blocks protocol-relative URLs', async () => {
|
|
595
|
-
const router = createRouter({ routes, url: '/' })
|
|
596
|
-
await router.push('//evil.com')
|
|
597
|
-
expect(router.currentRoute().path).toBe('/')
|
|
598
|
-
})
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
// ─── Router — staleWhileRevalidate ───────────────────────────────────────────
|
|
602
|
-
|
|
603
|
-
describe('router — staleWhileRevalidate', () => {
|
|
604
|
-
test('serves stale data immediately, revalidates in background', async () => {
|
|
605
|
-
let loaderCallCount = 0
|
|
606
|
-
const routes: RouteRecord[] = [
|
|
607
|
-
{ path: '/', component: Home },
|
|
608
|
-
{
|
|
609
|
-
path: '/data',
|
|
610
|
-
component: About,
|
|
611
|
-
staleWhileRevalidate: true,
|
|
612
|
-
loader: async () => {
|
|
613
|
-
loaderCallCount++
|
|
614
|
-
const v = loaderCallCount
|
|
615
|
-
// The revalidation call (2nd) takes REAL async time so the
|
|
616
|
-
// stale window is deterministic, not microtask-races. The
|
|
617
|
-
// first (blocking) call resolves immediately.
|
|
618
|
-
if (v >= 2) await new Promise<void>((r) => setTimeout(r, 40))
|
|
619
|
-
return `data-v${v}`
|
|
620
|
-
},
|
|
621
|
-
},
|
|
622
|
-
]
|
|
623
|
-
const swrRecord = routes[1] as RouteRecord
|
|
624
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
625
|
-
|
|
626
|
-
// First navigation — loader runs as blocking
|
|
627
|
-
await router.push('/data')
|
|
628
|
-
expect(loaderCallCount).toBe(1)
|
|
629
|
-
expect(router._loaderData.get(swrRecord)).toBe('data-v1')
|
|
630
|
-
|
|
631
|
-
// Navigate AWAY, then BACK. The away nav must NOT prune the SWR
|
|
632
|
-
// route's loader data (that was the bug — see the regression note
|
|
633
|
-
// below); on return the SWR fast-path must serve the STALE value
|
|
634
|
-
// immediately and revalidate in the BACKGROUND.
|
|
635
|
-
await router.push('/')
|
|
636
|
-
await router.push('/data')
|
|
637
|
-
|
|
638
|
-
// SWR discriminator (load-bearing): the return navigation resolves
|
|
639
|
-
// WITHOUT blocking on the (40ms) loader — the served data is still
|
|
640
|
-
// STALE `data-v1`, revalidation pending. Pre-fix this navigation
|
|
641
|
-
// went through the BLOCKING path (the prune wiped the entry), so
|
|
642
|
-
// `await push('/data')` would have awaited the loader and the data
|
|
643
|
-
// here would already be `data-v2`.
|
|
644
|
-
expect(router._loaderData.get(swrRecord)).toBe('data-v1')
|
|
645
|
-
|
|
646
|
-
// Background revalidation then completes and swaps in fresh data.
|
|
647
|
-
await new Promise<void>((r) => setTimeout(r, 80))
|
|
648
|
-
expect(loaderCallCount).toBe(2)
|
|
649
|
-
expect(router._loaderData.get(swrRecord)).toBe('data-v2')
|
|
650
|
-
})
|
|
651
|
-
|
|
652
|
-
test('a failing background revalidation surfaces via onError without cancelling or clobbering', async () => {
|
|
653
|
-
// This path only became testable after #617 made the SWR branch
|
|
654
|
-
// actually run (the prune previously made `revalidateSwrLoaders`
|
|
655
|
-
// dead code — the reason an earlier attempt at this fix shipped
|
|
656
|
-
// WITHOUT a test and was reverted). With SWR live, an empty `.catch`
|
|
657
|
-
// is the silent-failure anti-pattern: a persistently-failing
|
|
658
|
-
// revalidation gives the developer zero signal. Contract: the error
|
|
659
|
-
// is surfaced via `onError` exactly once; the (already-settled)
|
|
660
|
-
// navigation is NOT cancelled; the failed revalidation does NOT
|
|
661
|
-
// clobber the stale data (it stays valid + on screen).
|
|
662
|
-
let loaderCallCount = 0
|
|
663
|
-
const onError = vi.fn<(err: unknown, route: unknown) => undefined | false>(() => undefined)
|
|
664
|
-
const routes: RouteRecord[] = [
|
|
665
|
-
{ path: '/', component: Home },
|
|
666
|
-
{
|
|
667
|
-
path: '/data',
|
|
668
|
-
component: About,
|
|
669
|
-
staleWhileRevalidate: true,
|
|
670
|
-
loader: async () => {
|
|
671
|
-
loaderCallCount++
|
|
672
|
-
const v = loaderCallCount
|
|
673
|
-
if (v >= 2) {
|
|
674
|
-
// Real async delay so the rejection is genuinely a
|
|
675
|
-
// background failure (deterministic, not a microtask race).
|
|
676
|
-
await new Promise<void>((r) => setTimeout(r, 40))
|
|
677
|
-
throw new Error('revalidation upstream 503')
|
|
678
|
-
}
|
|
679
|
-
return `data-v${v}`
|
|
680
|
-
},
|
|
681
|
-
},
|
|
682
|
-
]
|
|
683
|
-
const swrRecord = routes[1] as RouteRecord
|
|
684
|
-
const router = createRouter({ routes, url: '/', onError }) as RouterInstance
|
|
685
|
-
|
|
686
|
-
await router.push('/data') // blocking — primes stale data-v1
|
|
687
|
-
expect(router._loaderData.get(swrRecord)).toBe('data-v1')
|
|
688
|
-
|
|
689
|
-
await router.push('/') // nav away (SWR data survives — #617)
|
|
690
|
-
await router.push('/data') // SWR: stale served, revalidate in bg
|
|
691
|
-
|
|
692
|
-
// Returned on STALE data without blocking; revalidation not failed yet.
|
|
693
|
-
expect(router.currentRoute().path).toBe('/data')
|
|
694
|
-
expect(router._loaderData.get(swrRecord)).toBe('data-v1')
|
|
695
|
-
expect(onError).not.toHaveBeenCalled()
|
|
696
|
-
|
|
697
|
-
await new Promise<void>((r) => setTimeout(r, 90)) // revalidation rejects
|
|
698
|
-
|
|
699
|
-
expect(loaderCallCount).toBe(2)
|
|
700
|
-
// The previously-empty `.catch` swallowed this entirely. Contract:
|
|
701
|
-
expect(onError).toHaveBeenCalledTimes(1)
|
|
702
|
-
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error)
|
|
703
|
-
expect((onError.mock.calls[0]?.[0] as Error).message).toBe('revalidation upstream 503')
|
|
704
|
-
// Navigation was NOT cancelled, and the stale value was NOT clobbered
|
|
705
|
-
// by the failed revalidation — it stays valid and on screen.
|
|
706
|
-
expect(router.currentRoute().path).toBe('/data')
|
|
707
|
-
expect(router._loaderData.get(swrRecord)).toBe('data-v1')
|
|
708
|
-
})
|
|
709
|
-
|
|
710
|
-
// REGRESSION NOTE (fixed in this PR). `staleWhileRevalidate` was
|
|
711
|
-
// effectively a no-op for the realistic nav-away/back case:
|
|
712
|
-
// `commitNavigation`'s `doCommit` pruned `_loaderData` for every
|
|
713
|
-
// record not in the NEW matched chain on every navigation, so
|
|
714
|
-
// navigating away deleted the SWR route's data and `runLoaders`'
|
|
715
|
-
// `r.staleWhileRevalidate && _loaderData.has(r)` gate was always false
|
|
716
|
-
// on return — `revalidateSwrLoaders` never ran; every visit went
|
|
717
|
-
// through the blocking path. (NB: the earlier hypothesis that
|
|
718
|
-
// `resolveRoute` returns fresh `RouteRecord` objects was WRONG —
|
|
719
|
-
// identity is stable; the prune was the sole cause, proven by an
|
|
720
|
-
// instrumented probe showing SWR fires correctly for `/data → /data`
|
|
721
|
-
// with no nav-away but not for `/data → / → /data`.) Fix: the prune
|
|
722
|
-
// skips `staleWhileRevalidate` records so their data survives
|
|
723
|
-
// navigating away. The strengthened test above is the regression
|
|
724
|
-
// guard — its stale-window assertion fails on the pre-fix prune.
|
|
725
|
-
})
|
|
726
|
-
|
|
727
|
-
describe('router.preload', () => {
|
|
728
|
-
test('runs loaders for the preloaded path', async () => {
|
|
729
|
-
let calls = 0
|
|
730
|
-
const routes: RouteRecord[] = [
|
|
731
|
-
{ path: '/', component: Home },
|
|
732
|
-
{
|
|
733
|
-
path: '/u/:id',
|
|
734
|
-
component: User,
|
|
735
|
-
loader: async ({ params }) => {
|
|
736
|
-
calls++
|
|
737
|
-
return { id: params.id, name: `User ${params.id}` }
|
|
738
|
-
},
|
|
739
|
-
},
|
|
740
|
-
]
|
|
741
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
742
|
-
|
|
743
|
-
await router.preload('/u/7')
|
|
744
|
-
|
|
745
|
-
expect(calls).toBe(1)
|
|
746
|
-
expect(router._loaderData.get(routes[1] as RouteRecord)).toEqual({
|
|
747
|
-
id: '7',
|
|
748
|
-
name: 'User 7',
|
|
749
|
-
})
|
|
750
|
-
// currentRoute is unchanged — preload prepares data, doesn't navigate
|
|
751
|
-
expect(router.currentRoute().path).toBe('/')
|
|
752
|
-
})
|
|
753
|
-
|
|
754
|
-
test('loads lazy components into the cache so render is synchronous', async () => {
|
|
755
|
-
let lazyLoadCalls = 0
|
|
756
|
-
const Lazy = () => null
|
|
757
|
-
const routes: RouteRecord[] = [
|
|
758
|
-
{ path: '/', component: Home },
|
|
759
|
-
{
|
|
760
|
-
path: '/lazy',
|
|
761
|
-
component: lazy(async () => {
|
|
762
|
-
lazyLoadCalls++
|
|
763
|
-
return Lazy
|
|
764
|
-
}),
|
|
765
|
-
},
|
|
766
|
-
]
|
|
767
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
768
|
-
|
|
769
|
-
await router.preload('/lazy')
|
|
770
|
-
|
|
771
|
-
expect(lazyLoadCalls).toBe(1)
|
|
772
|
-
expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
|
|
773
|
-
})
|
|
774
|
-
|
|
775
|
-
// ─── PR C — skipLoaders option for 404 build paths ──────────────────────
|
|
776
|
-
//
|
|
777
|
-
// The SSG plugin's `__renderNotFound` opts out of loader execution
|
|
778
|
-
// when generating a static 404 page — parent-layout loaders that hit
|
|
779
|
-
// auth resources / external APIs shouldn't fire when there's no real
|
|
780
|
-
// request context to drive them. `skipLoaders: true` skips the loader
|
|
781
|
-
// step entirely while keeping the lazy-component resolution intact
|
|
782
|
-
// (so the synthetic chain still renders cleanly).
|
|
783
|
-
test('skipLoaders: true skips loader execution', async () => {
|
|
784
|
-
let calls = 0
|
|
785
|
-
const routes: RouteRecord[] = [
|
|
786
|
-
{ path: '/', component: Home },
|
|
787
|
-
{
|
|
788
|
-
path: '/u/:id',
|
|
789
|
-
component: User,
|
|
790
|
-
loader: async ({ params }) => {
|
|
791
|
-
calls++
|
|
792
|
-
return { id: params.id }
|
|
793
|
-
},
|
|
794
|
-
},
|
|
795
|
-
]
|
|
796
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
797
|
-
|
|
798
|
-
await router.preload('/u/7', undefined, { skipLoaders: true })
|
|
799
|
-
|
|
800
|
-
expect(calls).toBe(0)
|
|
801
|
-
expect(router._loaderData.get(routes[1] as RouteRecord)).toBeUndefined()
|
|
802
|
-
})
|
|
803
|
-
|
|
804
|
-
test('skipLoaders: false (default) still runs loaders', async () => {
|
|
805
|
-
let calls = 0
|
|
806
|
-
const routes: RouteRecord[] = [
|
|
807
|
-
{ path: '/', component: Home },
|
|
808
|
-
{
|
|
809
|
-
path: '/u/:id',
|
|
810
|
-
component: User,
|
|
811
|
-
loader: async () => {
|
|
812
|
-
calls++
|
|
813
|
-
return null
|
|
814
|
-
},
|
|
815
|
-
},
|
|
816
|
-
]
|
|
817
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
818
|
-
|
|
819
|
-
// No options arg
|
|
820
|
-
await router.preload('/u/7')
|
|
821
|
-
expect(calls).toBe(1)
|
|
822
|
-
|
|
823
|
-
// Explicit false
|
|
824
|
-
await router.preload('/u/7', undefined, { skipLoaders: false })
|
|
825
|
-
expect(calls).toBe(2)
|
|
826
|
-
})
|
|
827
|
-
|
|
828
|
-
test('skipLoaders: true still loads lazy components (preserves render readiness)', async () => {
|
|
829
|
-
// The 404 build path needs the synthetic-chain components resolved
|
|
830
|
-
// so the render pass doesn't fall back to loadingComponent. Only
|
|
831
|
-
// the data-fetching `r.loader()` calls are skipped.
|
|
832
|
-
let lazyLoadCalls = 0
|
|
833
|
-
let loaderCalls = 0
|
|
834
|
-
const Lazy = () => null
|
|
835
|
-
const routes: RouteRecord[] = [
|
|
836
|
-
{ path: '/', component: Home },
|
|
837
|
-
{
|
|
838
|
-
path: '/lazy',
|
|
839
|
-
component: lazy(async () => {
|
|
840
|
-
lazyLoadCalls++
|
|
841
|
-
return Lazy
|
|
842
|
-
}),
|
|
843
|
-
loader: async () => {
|
|
844
|
-
loaderCalls++
|
|
845
|
-
return null
|
|
846
|
-
},
|
|
847
|
-
},
|
|
848
|
-
]
|
|
849
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
850
|
-
|
|
851
|
-
await router.preload('/lazy', undefined, { skipLoaders: true })
|
|
852
|
-
|
|
853
|
-
// Lazy component IS resolved (needed for render readiness).
|
|
854
|
-
expect(lazyLoadCalls).toBe(1)
|
|
855
|
-
expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
|
|
856
|
-
// Loader was NOT called.
|
|
857
|
-
expect(loaderCalls).toBe(0)
|
|
858
|
-
})
|
|
859
|
-
|
|
860
|
-
test('skipLoaders: true preserves currentRoute (preload is non-navigational)', async () => {
|
|
861
|
-
const routes: RouteRecord[] = [
|
|
862
|
-
{ path: '/', component: Home },
|
|
863
|
-
{
|
|
864
|
-
path: '/u/:id',
|
|
865
|
-
component: User,
|
|
866
|
-
loader: async () => null,
|
|
867
|
-
},
|
|
868
|
-
]
|
|
869
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
870
|
-
|
|
871
|
-
await router.preload('/u/7', undefined, { skipLoaders: true })
|
|
872
|
-
|
|
873
|
-
expect(router.currentRoute().path).toBe('/')
|
|
874
|
-
})
|
|
875
|
-
})
|
|
876
|
-
|
|
877
|
-
// ─── _loaderCache LRU cap (regression for missing _maxCacheSize wiring) ────
|
|
878
|
-
describe('router — _loaderCache LRU cap', () => {
|
|
879
|
-
// Pre-fix: `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
880
|
-
// (default 100) but the loader cache write paths in router.ts never read it
|
|
881
|
-
// — only `_componentCache` enforced the cap. Long-running SPAs navigating
|
|
882
|
-
// dynamic-param routes (`/posts/:id` with hundreds of unique IDs) would
|
|
883
|
-
// accumulate `_loaderCache` entries until manual `invalidateLoader()`.
|
|
884
|
-
// Post-fix: the helper `loaderCacheSet` evicts oldest (insertion-order FIFO)
|
|
885
|
-
// when over the cap, mirroring `_componentCache`.
|
|
886
|
-
test('caps _loaderCache at maxCacheSize, evicts oldest first', async () => {
|
|
887
|
-
const Page = () => null
|
|
888
|
-
const routes: RouteRecord[] = [
|
|
889
|
-
{
|
|
890
|
-
path: '/posts/:id',
|
|
891
|
-
component: Page,
|
|
892
|
-
loader: async ({ params }) => `post-${params.id}`,
|
|
893
|
-
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
894
|
-
},
|
|
895
|
-
]
|
|
896
|
-
const router = createRouter({ routes, maxCacheSize: 3, url: '/' }) as RouterInstance
|
|
897
|
-
|
|
898
|
-
// Drive the loader through 4 distinct keys
|
|
899
|
-
await router.push('/posts/1')
|
|
900
|
-
await router.push('/posts/2')
|
|
901
|
-
await router.push('/posts/3')
|
|
902
|
-
await router.push('/posts/4')
|
|
903
|
-
|
|
904
|
-
// Cache must be capped at 3 (not 4).
|
|
905
|
-
expect(router._loaderCache.size).toBe(3)
|
|
906
|
-
|
|
907
|
-
// FIFO: the OLDEST insertion (posts:1) must have been evicted.
|
|
908
|
-
expect(router._loaderCache.has('posts:1')).toBe(false)
|
|
909
|
-
expect(router._loaderCache.has('posts:2')).toBe(true)
|
|
910
|
-
expect(router._loaderCache.has('posts:3')).toBe(true)
|
|
911
|
-
expect(router._loaderCache.has('posts:4')).toBe(true)
|
|
912
|
-
})
|
|
913
|
-
|
|
914
|
-
test('does not evict when cap is not exceeded', async () => {
|
|
915
|
-
const Page = () => null
|
|
916
|
-
const routes: RouteRecord[] = [
|
|
917
|
-
{
|
|
918
|
-
path: '/posts/:id',
|
|
919
|
-
component: Page,
|
|
920
|
-
loader: async ({ params }) => `post-${params.id}`,
|
|
921
|
-
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
922
|
-
},
|
|
923
|
-
]
|
|
924
|
-
const router = createRouter({ routes, maxCacheSize: 100, url: '/' }) as RouterInstance
|
|
925
|
-
|
|
926
|
-
await router.push('/posts/1')
|
|
927
|
-
await router.push('/posts/2')
|
|
928
|
-
await router.push('/posts/3')
|
|
929
|
-
|
|
930
|
-
expect(router._loaderCache.size).toBe(3)
|
|
931
|
-
expect(router._loaderCache.has('posts:1')).toBe(true)
|
|
932
|
-
expect(router._loaderCache.has('posts:2')).toBe(true)
|
|
933
|
-
expect(router._loaderCache.has('posts:3')).toBe(true)
|
|
934
|
-
})
|
|
935
|
-
|
|
936
|
-
test('default maxCacheSize (100) caps cache after 100 unique keys', async () => {
|
|
937
|
-
const Page = () => null
|
|
938
|
-
const routes: RouteRecord[] = [
|
|
939
|
-
{
|
|
940
|
-
path: '/posts/:id',
|
|
941
|
-
component: Page,
|
|
942
|
-
loader: async ({ params }) => `post-${params.id}`,
|
|
943
|
-
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
944
|
-
},
|
|
945
|
-
]
|
|
946
|
-
// No explicit maxCacheSize — uses default 100
|
|
947
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
948
|
-
|
|
949
|
-
for (let i = 0; i < 105; i++) {
|
|
950
|
-
await router.push(`/posts/${i}`)
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
expect(router._loaderCache.size).toBe(100)
|
|
954
|
-
// Earliest 5 keys (0-4) evicted.
|
|
955
|
-
expect(router._loaderCache.has('posts:0')).toBe(false)
|
|
956
|
-
expect(router._loaderCache.has('posts:4')).toBe(false)
|
|
957
|
-
expect(router._loaderCache.has('posts:5')).toBe(true)
|
|
958
|
-
expect(router._loaderCache.has('posts:104')).toBe(true)
|
|
959
|
-
})
|
|
960
|
-
})
|
|
961
|
-
|
|
962
|
-
// ─── Regression: dedup must not return aborted in-flight promise ───────────
|
|
963
|
-
//
|
|
964
|
-
// Pre-fix: `router.push` aborts `_abortController` BEFORE starting the next
|
|
965
|
-
// nav. If two pushes to the same path happen back-to-back, the in-flight
|
|
966
|
-
// Map still holds nav-1's promise (its `.catch` hasn't run yet). The dedup
|
|
967
|
-
// returned that promise to nav-2 — but its bound signal is already aborted,
|
|
968
|
-
// so nav-2's data path is broken even though it has its own fresh signal.
|
|
969
|
-
//
|
|
970
|
-
// Post-fix: `_loaderInflight` stores `{ promise, signal }`. Dedup is gated
|
|
971
|
-
// on `!signal.aborted`. Aborted entries fall through to a fresh execute
|
|
972
|
-
// using nav-2's signal.
|
|
973
|
-
describe('router — _loaderInflight aborted-signal dedup', () => {
|
|
974
|
-
test('back-to-back navigation re-executes loader with fresh signal when previous was aborted', async () => {
|
|
975
|
-
let invocations = 0
|
|
976
|
-
let resolveLoader1: ((data: unknown) => void) | null = null
|
|
977
|
-
|
|
978
|
-
const Page = () => null
|
|
979
|
-
const routes: RouteRecord[] = [
|
|
980
|
-
{ path: '/', component: Page },
|
|
981
|
-
{
|
|
982
|
-
path: '/data',
|
|
983
|
-
component: Page,
|
|
984
|
-
loader: async ({ signal }) => {
|
|
985
|
-
invocations++
|
|
986
|
-
const myInvocation = invocations
|
|
987
|
-
// Wire signal-abort → reject so the nav actually fails on abort.
|
|
988
|
-
// The first invocation hangs until manually resolved; later
|
|
989
|
-
// invocations resolve immediately.
|
|
990
|
-
if (myInvocation === 1) {
|
|
991
|
-
return new Promise((_resolve, reject) => {
|
|
992
|
-
signal?.addEventListener('abort', () => reject(new Error('aborted')))
|
|
993
|
-
resolveLoader1 = _resolve
|
|
994
|
-
})
|
|
995
|
-
}
|
|
996
|
-
return `data-${myInvocation}`
|
|
997
|
-
},
|
|
998
|
-
},
|
|
999
|
-
]
|
|
1000
|
-
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
1001
|
-
|
|
1002
|
-
// Nav 1 → /data. Loader invocation #1 starts, hangs.
|
|
1003
|
-
const nav1 = router.push('/data').catch(() => {})
|
|
1004
|
-
await new Promise<void>((r) => queueMicrotask(() => r()))
|
|
1005
|
-
|
|
1006
|
-
// Nav 2 → /data. router.push aborts ac1 first, then calls executeLoader.
|
|
1007
|
-
// Pre-fix: dedup returns nav-1's promise (whose signal is now aborted).
|
|
1008
|
-
// Post-fix: dedup skipped (signal.aborted=true), fresh loader runs.
|
|
1009
|
-
const nav2 = router.push('/data')
|
|
1010
|
-
|
|
1011
|
-
// Resolve nav-1's hung promise (won't actually deliver — already aborted)
|
|
1012
|
-
const r1 = resolveLoader1 as ((d: unknown) => void) | null
|
|
1013
|
-
if (r1) r1('data-1')
|
|
1014
|
-
|
|
1015
|
-
await nav2
|
|
1016
|
-
await nav1
|
|
1017
|
-
|
|
1018
|
-
// Post-fix: 2 invocations (nav-1 aborted, nav-2 ran fresh).
|
|
1019
|
-
// Pre-fix: 1 invocation (nav-2 deduped to nav-1's aborted promise).
|
|
1020
|
-
expect(invocations).toBe(2)
|
|
1021
|
-
expect(router.currentRoute().path).toBe('/data')
|
|
1022
|
-
})
|
|
1023
|
-
|
|
1024
|
-
})
|