@pyreon/router 0.11.2 → 0.11.4
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +4 -1
- package/lib/index.js.map +1 -1
- package/package.json +4 -4
- package/src/router.ts +11 -1
- package/src/tests/loader.test.ts +515 -0
- package/src/tests/match.test.ts +467 -0
package/src/router.ts
CHANGED
|
@@ -529,8 +529,18 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
529
529
|
return "continue"
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: navigation is inherently multi-step
|
|
532
533
|
async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
|
|
533
|
-
if (redirectDepth > 10)
|
|
534
|
+
if (redirectDepth > 10) {
|
|
535
|
+
if (__DEV__) {
|
|
536
|
+
// biome-ignore lint/suspicious/noConsole: dev-only warning
|
|
537
|
+
console.warn(
|
|
538
|
+
`[Pyreon] Navigation to "${rawPath}" aborted: redirect depth exceeded 10 levels. ` +
|
|
539
|
+
"This likely indicates a redirect loop in your route configuration.",
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
return
|
|
543
|
+
}
|
|
534
544
|
|
|
535
545
|
const path = normalizeTrailingSlash(rawPath, trailingSlash)
|
|
536
546
|
const gen = ++_navGen
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData } from "../loader"
|
|
2
|
+
import { createRouter, setActiveRouter, useIsActive, useSearchParams } from "../router"
|
|
3
|
+
import type { RouteRecord, RouterInstance } from "../types"
|
|
4
|
+
|
|
5
|
+
const Home = () => null
|
|
6
|
+
const About = () => null
|
|
7
|
+
const User = () => null
|
|
8
|
+
|
|
9
|
+
// ─── serializeLoaderData / hydrateLoaderData round-trip edge cases ────────────
|
|
10
|
+
|
|
11
|
+
describe("loader data serialization — edge cases", () => {
|
|
12
|
+
test("serializes multiple route loaders", async () => {
|
|
13
|
+
const routes: RouteRecord[] = [
|
|
14
|
+
{
|
|
15
|
+
path: "/admin",
|
|
16
|
+
component: Home,
|
|
17
|
+
loader: async () => "admin-data",
|
|
18
|
+
children: [
|
|
19
|
+
{
|
|
20
|
+
path: "users",
|
|
21
|
+
component: About,
|
|
22
|
+
loader: async () => "users-data",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
const router = createRouter({ routes, url: "/admin/users" }) as RouterInstance
|
|
28
|
+
await prefetchLoaderData(router, "/admin/users")
|
|
29
|
+
|
|
30
|
+
const serialized = serializeLoaderData(router)
|
|
31
|
+
expect(serialized["/admin"]).toBe("admin-data")
|
|
32
|
+
expect(serialized.users).toBe("users-data")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("hydrate ignores paths not in current route matched", () => {
|
|
36
|
+
const routes: RouteRecord[] = [
|
|
37
|
+
{ path: "/", component: Home },
|
|
38
|
+
{ path: "/page", component: About, loader: async () => [] },
|
|
39
|
+
]
|
|
40
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
41
|
+
// Hydrate with data for a path that is NOT currently matched
|
|
42
|
+
hydrateLoaderData(router, { "/page": "should-be-ignored" })
|
|
43
|
+
expect(router._loaderData.size).toBe(0)
|
|
44
|
+
})
|
|
45
|
+
|
|
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
|
+
|
|
50
|
+
// These should not throw
|
|
51
|
+
hydrateLoaderData(router, null as unknown as Record<string, unknown>)
|
|
52
|
+
hydrateLoaderData(router, undefined as unknown as Record<string, unknown>)
|
|
53
|
+
hydrateLoaderData(router, 42 as unknown as Record<string, unknown>)
|
|
54
|
+
expect(router._loaderData.size).toBe(0)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("round-trip with complex data types", async () => {
|
|
58
|
+
const complexData = {
|
|
59
|
+
items: [
|
|
60
|
+
{ id: 1, name: "Item 1" },
|
|
61
|
+
{ id: 2, name: "Item 2" },
|
|
62
|
+
],
|
|
63
|
+
meta: { total: 2, page: 1 },
|
|
64
|
+
nested: { deep: { value: true } },
|
|
65
|
+
}
|
|
66
|
+
const routes: RouteRecord[] = [
|
|
67
|
+
{ path: "/data", component: Home, loader: async () => complexData },
|
|
68
|
+
]
|
|
69
|
+
const ssrRouter = createRouter({ routes, url: "/data" }) as RouterInstance
|
|
70
|
+
await prefetchLoaderData(ssrRouter, "/data")
|
|
71
|
+
|
|
72
|
+
const serialized = serializeLoaderData(ssrRouter)
|
|
73
|
+
const clientRouter = createRouter({ routes, url: "/data" }) as RouterInstance
|
|
74
|
+
hydrateLoaderData(clientRouter, serialized)
|
|
75
|
+
|
|
76
|
+
const values = Array.from(clientRouter._loaderData.values())
|
|
77
|
+
expect(values[0]).toEqual(complexData)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("prefetchLoaderData passes AbortSignal to loaders", async () => {
|
|
81
|
+
let receivedSignal: AbortSignal | undefined
|
|
82
|
+
const routes: RouteRecord[] = [
|
|
83
|
+
{
|
|
84
|
+
path: "/data",
|
|
85
|
+
component: Home,
|
|
86
|
+
loader: async ({ signal }) => {
|
|
87
|
+
receivedSignal = signal
|
|
88
|
+
return "ok"
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
93
|
+
await prefetchLoaderData(router, "/data")
|
|
94
|
+
expect(receivedSignal).toBeDefined()
|
|
95
|
+
expect(receivedSignal).toBeInstanceOf(AbortSignal)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("prefetchLoaderData skips routes without loaders", async () => {
|
|
99
|
+
const routes: RouteRecord[] = [
|
|
100
|
+
{
|
|
101
|
+
path: "/admin",
|
|
102
|
+
component: Home,
|
|
103
|
+
children: [
|
|
104
|
+
{ path: "users", component: About }, // no loader
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
109
|
+
await prefetchLoaderData(router, "/admin/users")
|
|
110
|
+
expect(router._loaderData.size).toBe(0)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ─── useIsActive — edge cases ────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("useIsActive — edge cases", () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
setActiveRouter(null)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("throws when no router installed", () => {
|
|
122
|
+
expect(() => useIsActive("/")).toThrow("[pyreon-router] No router installed")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("exact match for root path", () => {
|
|
126
|
+
const router = createRouter({ routes: [{ path: "/", component: Home }], url: "/" })
|
|
127
|
+
setActiveRouter(router as RouterInstance)
|
|
128
|
+
const isActive = useIsActive("/", true)
|
|
129
|
+
expect(isActive()).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("partial match: /admin matches /admin/users", () => {
|
|
133
|
+
const routes: RouteRecord[] = [
|
|
134
|
+
{
|
|
135
|
+
path: "/admin",
|
|
136
|
+
component: Home,
|
|
137
|
+
children: [{ path: "users", component: About }],
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
const router = createRouter({ routes, url: "/admin/users" })
|
|
141
|
+
setActiveRouter(router as RouterInstance)
|
|
142
|
+
const isActive = useIsActive("/admin")
|
|
143
|
+
expect(isActive()).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("partial match: /admin does NOT match /admin-panel", async () => {
|
|
147
|
+
const routes: RouteRecord[] = [
|
|
148
|
+
{ path: "/admin", component: Home },
|
|
149
|
+
{ path: "/admin-panel", component: About },
|
|
150
|
+
]
|
|
151
|
+
const router = createRouter({ routes, url: "/admin-panel" })
|
|
152
|
+
setActiveRouter(router as RouterInstance)
|
|
153
|
+
const isActive = useIsActive("/admin")
|
|
154
|
+
expect(isActive()).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("exact match: /admin does NOT match /admin/users", () => {
|
|
158
|
+
const routes: RouteRecord[] = [
|
|
159
|
+
{
|
|
160
|
+
path: "/admin",
|
|
161
|
+
component: Home,
|
|
162
|
+
children: [{ path: "users", component: About }],
|
|
163
|
+
},
|
|
164
|
+
]
|
|
165
|
+
const router = createRouter({ routes, url: "/admin/users" })
|
|
166
|
+
setActiveRouter(router as RouterInstance)
|
|
167
|
+
const isActive = useIsActive("/admin", true)
|
|
168
|
+
expect(isActive()).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("root path partial match only matches /", () => {
|
|
172
|
+
const routes: RouteRecord[] = [
|
|
173
|
+
{ path: "/", component: Home },
|
|
174
|
+
{ path: "/about", component: About },
|
|
175
|
+
]
|
|
176
|
+
const router = createRouter({ routes, url: "/about" })
|
|
177
|
+
setActiveRouter(router as RouterInstance)
|
|
178
|
+
const isActive = useIsActive("/")
|
|
179
|
+
// Root path in partial mode should only match "/"
|
|
180
|
+
expect(isActive()).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
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
|
+
setActiveRouter(router as RouterInstance)
|
|
187
|
+
const isActive = useIsActive("/user/:id", true)
|
|
188
|
+
expect(isActive()).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("param pattern: /user/:id matches /user/42 in partial mode", () => {
|
|
192
|
+
const routes: RouteRecord[] = [
|
|
193
|
+
{
|
|
194
|
+
path: "/user/:id",
|
|
195
|
+
component: User,
|
|
196
|
+
children: [{ path: "posts", component: About }],
|
|
197
|
+
},
|
|
198
|
+
]
|
|
199
|
+
const router = createRouter({ routes, url: "/user/42/posts" })
|
|
200
|
+
setActiveRouter(router as RouterInstance)
|
|
201
|
+
const isActive = useIsActive("/user/:id")
|
|
202
|
+
expect(isActive()).toBe(true)
|
|
203
|
+
})
|
|
204
|
+
|
|
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
|
+
setActiveRouter(router as RouterInstance)
|
|
209
|
+
const isActive = useIsActive("/a/b", true)
|
|
210
|
+
expect(isActive()).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
|
|
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
|
+
setActiveRouter(router as RouterInstance)
|
|
217
|
+
const isActive = useIsActive("/a/b/c")
|
|
218
|
+
expect(isActive()).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ─── useSearchParams — edge cases ────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
describe("useSearchParams — edge cases", () => {
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
setActiveRouter(null)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("throws when no router installed", () => {
|
|
230
|
+
expect(() => useSearchParams()).toThrow("[pyreon-router] No router installed")
|
|
231
|
+
})
|
|
232
|
+
|
|
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
|
+
setActiveRouter(router as RouterInstance)
|
|
237
|
+
const [get] = useSearchParams()
|
|
238
|
+
expect(get().q).toBe("hello")
|
|
239
|
+
expect(get().page).toBe("1")
|
|
240
|
+
})
|
|
241
|
+
|
|
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
|
+
setActiveRouter(router as RouterInstance)
|
|
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
|
+
})
|
|
251
|
+
|
|
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
|
+
setActiveRouter(router as RouterInstance)
|
|
256
|
+
const [, set] = useSearchParams({ q: "", page: "1" })
|
|
257
|
+
|
|
258
|
+
await set({ page: "2" })
|
|
259
|
+
// Router should navigate — check that the route updated
|
|
260
|
+
const route = router.currentRoute()
|
|
261
|
+
expect(route.query.page).toBe("2")
|
|
262
|
+
expect(route.query.q).toBe("hello")
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ─── Router — trailing slash normalization ───────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe("router — trailing slash normalization", () => {
|
|
269
|
+
const routes: RouteRecord[] = [
|
|
270
|
+
{ path: "/", component: Home },
|
|
271
|
+
{ path: "/about", component: About },
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
test("strip mode (default) removes trailing slashes", () => {
|
|
275
|
+
const router = createRouter({ routes, url: "/about/" })
|
|
276
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test("add mode ensures trailing slashes", () => {
|
|
280
|
+
const router = createRouter({ routes, url: "/about", trailingSlash: "add" })
|
|
281
|
+
expect(router.currentRoute().path).toBe("/about/")
|
|
282
|
+
})
|
|
283
|
+
|
|
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
|
+
})
|
|
288
|
+
|
|
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
|
+
})
|
|
293
|
+
|
|
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
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// ─── Router — onError handler ────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
describe("router — onError handler", () => {
|
|
305
|
+
test("onError receives error from failed loader", async () => {
|
|
306
|
+
const errors: unknown[] = []
|
|
307
|
+
const routes: RouteRecord[] = [
|
|
308
|
+
{ path: "/", component: Home },
|
|
309
|
+
{
|
|
310
|
+
path: "/fail",
|
|
311
|
+
component: About,
|
|
312
|
+
loader: async () => {
|
|
313
|
+
throw new Error("loader-error")
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
]
|
|
317
|
+
const router = createRouter({
|
|
318
|
+
routes,
|
|
319
|
+
url: "/",
|
|
320
|
+
onError: (err) => {
|
|
321
|
+
errors.push(err)
|
|
322
|
+
return undefined
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await router.push("/fail")
|
|
327
|
+
expect(errors.length).toBe(1)
|
|
328
|
+
expect((errors[0] as Error).message).toBe("loader-error")
|
|
329
|
+
expect(router.currentRoute().path).toBe("/fail")
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test("onError returning false cancels navigation", async () => {
|
|
333
|
+
const routes: RouteRecord[] = [
|
|
334
|
+
{ path: "/", component: Home },
|
|
335
|
+
{
|
|
336
|
+
path: "/fail",
|
|
337
|
+
component: About,
|
|
338
|
+
loader: async () => {
|
|
339
|
+
throw new Error("fail")
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
]
|
|
343
|
+
const router = createRouter({
|
|
344
|
+
routes,
|
|
345
|
+
url: "/",
|
|
346
|
+
onError: () => false,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
await router.push("/fail")
|
|
350
|
+
expect(router.currentRoute().path).toBe("/")
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// ─── Router — destroy ────────────────────────────────────────────────────────
|
|
355
|
+
|
|
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
|
+
router.beforeEach(() => true)
|
|
361
|
+
router.afterEach(() => {})
|
|
362
|
+
router._blockers.add(() => false)
|
|
363
|
+
router._loaderData.set(routes[0] as RouteRecord, "data")
|
|
364
|
+
|
|
365
|
+
router.destroy()
|
|
366
|
+
|
|
367
|
+
expect(router._blockers.size).toBe(0)
|
|
368
|
+
expect(router._loaderData.size).toBe(0)
|
|
369
|
+
expect(router._abortController).toBeNull()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test("destroy is idempotent (safe to call twice)", () => {
|
|
373
|
+
const routes: RouteRecord[] = [{ path: "/", component: Home }]
|
|
374
|
+
const router = createRouter({ routes, url: "/" })
|
|
375
|
+
expect(() => {
|
|
376
|
+
router.destroy()
|
|
377
|
+
router.destroy()
|
|
378
|
+
}).not.toThrow()
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ─── Router — isReady ────────────────────────────────────────────────────────
|
|
383
|
+
|
|
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
|
+
await router.isReady()
|
|
389
|
+
// Should not hang
|
|
390
|
+
expect(router.currentRoute().path).toBe("/")
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
// ─── Router — relative path navigation ───────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
describe("router — relative path navigation", () => {
|
|
397
|
+
const routes: RouteRecord[] = [
|
|
398
|
+
{ path: "/", component: Home },
|
|
399
|
+
{ path: "/user/:id", component: User },
|
|
400
|
+
{
|
|
401
|
+
path: "/admin",
|
|
402
|
+
component: Home,
|
|
403
|
+
children: [
|
|
404
|
+
{ path: "users", component: About },
|
|
405
|
+
{ path: "settings", component: User },
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
]
|
|
409
|
+
|
|
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
|
+
})
|
|
415
|
+
|
|
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
|
+
})
|
|
421
|
+
|
|
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
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// ─── Router — replace with named route ───────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
describe("router — replace with named route", () => {
|
|
432
|
+
test("replace with named route navigates correctly", async () => {
|
|
433
|
+
const routes: RouteRecord[] = [
|
|
434
|
+
{ path: "/", component: Home, name: "home" },
|
|
435
|
+
{ path: "/user/:id", component: User, name: "user" },
|
|
436
|
+
]
|
|
437
|
+
const router = createRouter({ routes, url: "/" })
|
|
438
|
+
await router.replace({ name: "user", params: { id: "42" } })
|
|
439
|
+
expect(router.currentRoute().path).toBe("/user/42")
|
|
440
|
+
})
|
|
441
|
+
|
|
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
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// ─── Router — sanitize unsafe URLs ───────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
describe("router — URL sanitization", () => {
|
|
453
|
+
const routes: RouteRecord[] = [
|
|
454
|
+
{ path: "/", component: Home },
|
|
455
|
+
{ path: "/about", component: About },
|
|
456
|
+
]
|
|
457
|
+
|
|
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
|
+
})
|
|
463
|
+
|
|
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
|
+
})
|
|
469
|
+
|
|
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
|
+
})
|
|
475
|
+
|
|
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
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// ─── Router — staleWhileRevalidate ───────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
describe("router — staleWhileRevalidate", () => {
|
|
486
|
+
test("serves stale data immediately, revalidates in background", async () => {
|
|
487
|
+
let loaderCallCount = 0
|
|
488
|
+
const routes: RouteRecord[] = [
|
|
489
|
+
{ path: "/", component: Home },
|
|
490
|
+
{
|
|
491
|
+
path: "/data",
|
|
492
|
+
component: About,
|
|
493
|
+
staleWhileRevalidate: true,
|
|
494
|
+
loader: async () => {
|
|
495
|
+
loaderCallCount++
|
|
496
|
+
return `data-v${loaderCallCount}`
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
]
|
|
500
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
501
|
+
|
|
502
|
+
// First navigation — loader runs as blocking
|
|
503
|
+
await router.push("/data")
|
|
504
|
+
expect(loaderCallCount).toBe(1)
|
|
505
|
+
expect(router._loaderData.get(routes[1] as RouteRecord)).toBe("data-v1")
|
|
506
|
+
|
|
507
|
+
// Navigate away and back — should show stale data and revalidate
|
|
508
|
+
await router.push("/")
|
|
509
|
+
await router.push("/data")
|
|
510
|
+
|
|
511
|
+
// Give background revalidation time
|
|
512
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
513
|
+
expect(loaderCallCount).toBe(2)
|
|
514
|
+
})
|
|
515
|
+
})
|