@pyreon/router 0.1.0
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/LICENSE +21 -0
- package/README.md +90 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +830 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +690 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +322 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/components.tsx +307 -0
- package/src/index.ts +78 -0
- package/src/loader.ts +97 -0
- package/src/match.ts +264 -0
- package/src/router.ts +451 -0
- package/src/scroll.ts +55 -0
- package/src/tests/router.test.ts +3294 -0
- package/src/tests/setup.ts +3 -0
- package/src/types.ts +242 -0
|
@@ -0,0 +1,3294 @@
|
|
|
1
|
+
import { h, popContext } from "@pyreon/core"
|
|
2
|
+
import { mount } from "@pyreon/runtime-dom"
|
|
3
|
+
import type { ResolvedRoute, RouteRecord } from "../index"
|
|
4
|
+
import {
|
|
5
|
+
createRouter,
|
|
6
|
+
hydrateLoaderData,
|
|
7
|
+
lazy,
|
|
8
|
+
prefetchLoaderData,
|
|
9
|
+
RouterLink,
|
|
10
|
+
RouterProvider,
|
|
11
|
+
RouterView,
|
|
12
|
+
serializeLoaderData,
|
|
13
|
+
useLoaderData,
|
|
14
|
+
useRoute,
|
|
15
|
+
useRouter,
|
|
16
|
+
} from "../index"
|
|
17
|
+
import {
|
|
18
|
+
buildNameIndex,
|
|
19
|
+
buildPath,
|
|
20
|
+
findRouteByName,
|
|
21
|
+
matchPath,
|
|
22
|
+
parseQuery,
|
|
23
|
+
resolveRoute,
|
|
24
|
+
stringifyQuery,
|
|
25
|
+
} from "../match"
|
|
26
|
+
import { getActiveRouter, setActiveRouter } from "../router"
|
|
27
|
+
import { ScrollManager } from "../scroll"
|
|
28
|
+
import type { RouterInstance } from "../types"
|
|
29
|
+
|
|
30
|
+
// Access internal _resolve without DOM
|
|
31
|
+
function resolveOn(routes: RouteRecord[], path: string) {
|
|
32
|
+
const router = createRouter(routes) as ReturnType<typeof createRouter> & {
|
|
33
|
+
_resolve(
|
|
34
|
+
path: string,
|
|
35
|
+
): ReturnType<typeof import("../index").createRouter> extends { currentRoute: () => infer R }
|
|
36
|
+
? R
|
|
37
|
+
: never
|
|
38
|
+
}
|
|
39
|
+
return (router as unknown as { _resolve(p: string): unknown })._resolve(path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Home = () => null
|
|
43
|
+
const About = () => null
|
|
44
|
+
const User = () => null
|
|
45
|
+
const NotFound = () => null
|
|
46
|
+
const AdminLayout = () => null
|
|
47
|
+
const AdminUsers = () => null
|
|
48
|
+
const AdminSettings = () => null
|
|
49
|
+
const routes: RouteRecord[] = [
|
|
50
|
+
{ path: "/", component: Home },
|
|
51
|
+
{ path: "/about", component: About },
|
|
52
|
+
{ path: "/user/:id", component: User },
|
|
53
|
+
{ path: "*", component: NotFound },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
// ─── Route matching ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("route matching", () => {
|
|
59
|
+
test("matches root path", () => {
|
|
60
|
+
const r = resolveOn(routes, "/") as { matched: { component: unknown }[] }
|
|
61
|
+
expect(r.matched[r.matched.length - 1]?.component).toBe(Home)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("matches static path", () => {
|
|
65
|
+
const r = resolveOn(routes, "/about") as { matched: { component: unknown }[] }
|
|
66
|
+
expect(r.matched[r.matched.length - 1]?.component).toBe(About)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("extracts dynamic param", () => {
|
|
70
|
+
const r = resolveOn(routes, "/user/42") as {
|
|
71
|
+
params: Record<string, string>
|
|
72
|
+
matched: unknown[]
|
|
73
|
+
}
|
|
74
|
+
expect(r.params.id).toBe("42")
|
|
75
|
+
expect(r.matched.length).toBeGreaterThan(0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("returns empty matched for unknown path", () => {
|
|
79
|
+
const r = resolveOn([{ path: "/", component: Home }], "/unknown") as { matched: unknown[] }
|
|
80
|
+
expect(r.matched).toHaveLength(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("parses query string", () => {
|
|
84
|
+
const r = resolveOn(routes, "/?foo=bar&baz=qux") as { query: Record<string, string> }
|
|
85
|
+
expect(r.query.foo).toBe("bar")
|
|
86
|
+
expect(r.query.baz).toBe("qux")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("parses hash fragment", () => {
|
|
90
|
+
const r = resolveOn(routes, "/about#section") as { hash: string }
|
|
91
|
+
expect(r.hash).toBe("section")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("matches wildcard route", () => {
|
|
95
|
+
const r = resolveOn(routes, "/anything/here") as { matched: { component: unknown }[] }
|
|
96
|
+
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("matches (.*) wildcard pattern", () => {
|
|
100
|
+
const routesWithCatch: RouteRecord[] = [
|
|
101
|
+
{ path: "/", component: Home },
|
|
102
|
+
{ path: "(.*)", component: NotFound },
|
|
103
|
+
]
|
|
104
|
+
const r = resolveOn(routesWithCatch, "/anything") as { matched: { component: unknown }[] }
|
|
105
|
+
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("decodes URI-encoded params", () => {
|
|
109
|
+
const r = resolveOn(routes, "/user/hello%20world") as { params: Record<string, string> }
|
|
110
|
+
expect(r.params.id).toBe("hello world")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("handles query without value", () => {
|
|
114
|
+
const r = resolveOn(routes, "/?flag") as { query: Record<string, string> }
|
|
115
|
+
expect(r.query.flag).toBe("")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("handles empty query string", () => {
|
|
119
|
+
const r = resolveOn(routes, "/about?") as { query: Record<string, string> }
|
|
120
|
+
expect(Object.keys(r.query)).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ─── Nested routes ───────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("nested route matching", () => {
|
|
127
|
+
const nestedRoutes: RouteRecord[] = [
|
|
128
|
+
{
|
|
129
|
+
path: "/admin",
|
|
130
|
+
component: AdminLayout,
|
|
131
|
+
meta: { requiresAuth: true },
|
|
132
|
+
children: [
|
|
133
|
+
{ path: "users", component: AdminUsers },
|
|
134
|
+
{ path: "settings", component: AdminSettings },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{ path: "/", component: Home },
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
test("matches nested child route", () => {
|
|
141
|
+
const r = resolveRoute("/admin/users", nestedRoutes)
|
|
142
|
+
expect(r.matched).toHaveLength(2)
|
|
143
|
+
expect(r.matched[0]?.component).toBe(AdminLayout)
|
|
144
|
+
expect(r.matched[1]?.component).toBe(AdminUsers)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("merges meta from parent and child", () => {
|
|
148
|
+
const routesWithMeta: RouteRecord[] = [
|
|
149
|
+
{
|
|
150
|
+
path: "/admin",
|
|
151
|
+
component: AdminLayout,
|
|
152
|
+
meta: { requiresAuth: true, title: "Admin" },
|
|
153
|
+
children: [{ path: "users", component: AdminUsers, meta: { title: "Users" } }],
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
const r = resolveRoute("/admin/users", routesWithMeta)
|
|
157
|
+
expect(r.meta.requiresAuth).toBe(true)
|
|
158
|
+
expect(r.meta.title).toBe("Users") // child overrides parent
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("matches parent route without child", () => {
|
|
162
|
+
const r = resolveRoute("/admin", nestedRoutes)
|
|
163
|
+
expect(r.matched.length).toBeGreaterThan(0)
|
|
164
|
+
expect(r.matched[0]?.component).toBe(AdminLayout)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ─── matchPath direct tests ──────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe("matchPath", () => {
|
|
171
|
+
test("returns null for segment count mismatch", () => {
|
|
172
|
+
expect(matchPath("/a/b", "/a")).toBeNull()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("returns null for non-matching static segment", () => {
|
|
176
|
+
expect(matchPath("/foo", "/bar")).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("matches exact static segments", () => {
|
|
180
|
+
expect(matchPath("/foo/bar", "/foo/bar")).toEqual({})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("extracts multiple params", () => {
|
|
184
|
+
const result = matchPath("/user/:id/post/:postId", "/user/42/post/99")
|
|
185
|
+
expect(result).toEqual({ id: "42", postId: "99" })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test("wildcard * matches any path", () => {
|
|
189
|
+
expect(matchPath("*", "/any/path")).toEqual({})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("wildcard (.*) matches any path", () => {
|
|
193
|
+
expect(matchPath("(.*)", "/any/path")).toEqual({})
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// ─── parseQuery / stringifyQuery ─────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe("parseQuery", () => {
|
|
200
|
+
test("parses multiple key-value pairs", () => {
|
|
201
|
+
expect(parseQuery("a=1&b=2")).toEqual({ a: "1", b: "2" })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("handles keys without values", () => {
|
|
205
|
+
expect(parseQuery("flag")).toEqual({ flag: "" })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("returns empty object for empty string", () => {
|
|
209
|
+
expect(parseQuery("")).toEqual({})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("decodes URI-encoded values", () => {
|
|
213
|
+
expect(parseQuery("name=hello%20world")).toEqual({ name: "hello world" })
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe("stringifyQuery", () => {
|
|
218
|
+
test("serializes key-value pairs", () => {
|
|
219
|
+
expect(stringifyQuery({ a: "1", b: "2" })).toBe("?a=1&b=2")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("returns empty string for empty object", () => {
|
|
223
|
+
expect(stringifyQuery({})).toBe("")
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test("handles key without value (empty string)", () => {
|
|
227
|
+
const result = stringifyQuery({ flag: "" })
|
|
228
|
+
expect(result).toBe("?flag")
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// ─── buildPath ───────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe("buildPath", () => {
|
|
235
|
+
test("replaces :param segments", () => {
|
|
236
|
+
expect(buildPath("/user/:id", { id: "42" })).toBe("/user/42")
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("replaces multiple params", () => {
|
|
240
|
+
expect(buildPath("/user/:id/post/:postId", { id: "1", postId: "99" })).toBe("/user/1/post/99")
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test("encodes param values", () => {
|
|
244
|
+
expect(buildPath("/user/:name", { name: "hello world" })).toBe("/user/hello%20world")
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ─── findRouteByName ─────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe("findRouteByName", () => {
|
|
251
|
+
const namedRoutes: RouteRecord[] = [
|
|
252
|
+
{ path: "/", component: Home, name: "home" },
|
|
253
|
+
{
|
|
254
|
+
path: "/admin",
|
|
255
|
+
component: AdminLayout,
|
|
256
|
+
name: "admin",
|
|
257
|
+
children: [{ path: "users", component: AdminUsers, name: "admin-users" }],
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
test("finds top-level named route", () => {
|
|
262
|
+
const found = findRouteByName("home", namedRoutes)
|
|
263
|
+
expect(found).not.toBeNull()
|
|
264
|
+
expect(found?.path).toBe("/")
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test("finds nested named route", () => {
|
|
268
|
+
const found = findRouteByName("admin-users", namedRoutes)
|
|
269
|
+
expect(found).not.toBeNull()
|
|
270
|
+
expect(found?.path).toBe("users")
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test("returns null for unknown name", () => {
|
|
274
|
+
expect(findRouteByName("unknown", namedRoutes)).toBeNull()
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// ─── buildNameIndex ──────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
describe("buildNameIndex", () => {
|
|
281
|
+
test("builds index from flat routes", () => {
|
|
282
|
+
const rs: RouteRecord[] = [
|
|
283
|
+
{ path: "/", component: Home, name: "home" },
|
|
284
|
+
{ path: "/about", component: About, name: "about" },
|
|
285
|
+
]
|
|
286
|
+
const index = buildNameIndex(rs)
|
|
287
|
+
expect(index.size).toBe(2)
|
|
288
|
+
expect(index.get("home")?.path).toBe("/")
|
|
289
|
+
expect(index.get("about")?.path).toBe("/about")
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test("builds index including nested named routes", () => {
|
|
293
|
+
const rs: RouteRecord[] = [
|
|
294
|
+
{
|
|
295
|
+
path: "/admin",
|
|
296
|
+
component: AdminLayout,
|
|
297
|
+
name: "admin",
|
|
298
|
+
children: [{ path: "users", component: AdminUsers, name: "admin-users" }],
|
|
299
|
+
},
|
|
300
|
+
]
|
|
301
|
+
const index = buildNameIndex(rs)
|
|
302
|
+
expect(index.get("admin")).toBeDefined()
|
|
303
|
+
expect(index.get("admin-users")).toBeDefined()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("skips unnamed routes", () => {
|
|
307
|
+
const rs: RouteRecord[] = [
|
|
308
|
+
{ path: "/", component: Home },
|
|
309
|
+
{ path: "/about", component: About, name: "about" },
|
|
310
|
+
]
|
|
311
|
+
const index = buildNameIndex(rs)
|
|
312
|
+
expect(index.size).toBe(1)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// ─── Router navigation (SSR mode — no window) ───────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe("router navigation", () => {
|
|
319
|
+
test("push updates currentRoute in SSR mode", async () => {
|
|
320
|
+
const router = createRouter({ routes, url: "/" })
|
|
321
|
+
await router.push("/about")
|
|
322
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test("replace updates currentRoute in SSR mode", async () => {
|
|
326
|
+
const router = createRouter({ routes, url: "/" })
|
|
327
|
+
await router.replace("/about")
|
|
328
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test("push with named route", async () => {
|
|
332
|
+
const namedRoutes: RouteRecord[] = [
|
|
333
|
+
{ path: "/", component: Home, name: "home" },
|
|
334
|
+
{ path: "/user/:id", component: User, name: "user" },
|
|
335
|
+
]
|
|
336
|
+
const router = createRouter({ routes: namedRoutes, url: "/" })
|
|
337
|
+
await router.push({ name: "user", params: { id: "42" } })
|
|
338
|
+
expect(router.currentRoute().path).toBe("/user/42")
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test("push with named route and query", async () => {
|
|
342
|
+
const namedRoutes: RouteRecord[] = [
|
|
343
|
+
{ path: "/", component: Home, name: "home" },
|
|
344
|
+
{ path: "/search", component: About, name: "search" },
|
|
345
|
+
]
|
|
346
|
+
const router = createRouter({ routes: namedRoutes, url: "/" })
|
|
347
|
+
await router.push({ name: "search", query: { q: "hello" } })
|
|
348
|
+
expect(router.currentRoute().path).toBe("/search")
|
|
349
|
+
expect(router.currentRoute().query.q).toBe("hello")
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test("push with unknown named route falls back to /", async () => {
|
|
353
|
+
const router = createRouter({ routes, url: "/about" })
|
|
354
|
+
await router.push({ name: "nonexistent" })
|
|
355
|
+
expect(router.currentRoute().path).toBe("/")
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test("back() does not throw in SSR (no window)", () => {
|
|
359
|
+
const router = createRouter({ routes, url: "/" })
|
|
360
|
+
expect(() => router.back()).not.toThrow()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test("sanitizes javascript: URI in push", async () => {
|
|
364
|
+
const router = createRouter({ routes, url: "/" })
|
|
365
|
+
await router.push("javascript:alert(1)")
|
|
366
|
+
expect(router.currentRoute().path).toBe("/")
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test("sanitizes data: URI in push", async () => {
|
|
370
|
+
const router = createRouter({ routes, url: "/" })
|
|
371
|
+
await router.push("data:text/html,test")
|
|
372
|
+
expect(router.currentRoute().path).toBe("/")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("sanitizes javascript: URI in replace", async () => {
|
|
376
|
+
const router = createRouter({ routes, url: "/" })
|
|
377
|
+
await router.replace("javascript:alert(1)")
|
|
378
|
+
expect(router.currentRoute().path).toBe("/")
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ─── createRouter options ────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
describe("createRouter options", () => {
|
|
385
|
+
test("accepts array shorthand (routes without options wrapper)", () => {
|
|
386
|
+
const router = createRouter(routes)
|
|
387
|
+
expect(router.currentRoute().path).toBe("/")
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test("uses url option for SSR initial location", () => {
|
|
391
|
+
const router = createRouter({ routes, url: "/about" })
|
|
392
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test("defaults to hash mode", () => {
|
|
396
|
+
const router = createRouter(routes) as RouterInstance
|
|
397
|
+
expect(router.mode).toBe("hash")
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test("supports history mode option", () => {
|
|
401
|
+
const router = createRouter({ routes, mode: "history" }) as RouterInstance
|
|
402
|
+
expect(router.mode).toBe("history")
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// ─── beforeEnter guard ───────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
describe("beforeEnter guard", () => {
|
|
409
|
+
test("beforeEnter can block navigation", async () => {
|
|
410
|
+
const guardRoutes: RouteRecord[] = [
|
|
411
|
+
{ path: "/", component: Home },
|
|
412
|
+
{ path: "/protected", component: About, beforeEnter: () => false },
|
|
413
|
+
]
|
|
414
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
415
|
+
await router.push("/protected")
|
|
416
|
+
expect(router.currentRoute().path).toBe("/")
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test("beforeEnter can redirect", async () => {
|
|
420
|
+
const guardRoutes: RouteRecord[] = [
|
|
421
|
+
{ path: "/", component: Home },
|
|
422
|
+
{ path: "/old", component: About, beforeEnter: () => "/new" },
|
|
423
|
+
{ path: "/new", component: User },
|
|
424
|
+
]
|
|
425
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
426
|
+
await router.push("/old")
|
|
427
|
+
expect(router.currentRoute().path).toBe("/new")
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
test("beforeEnter allows navigation when returning true", async () => {
|
|
431
|
+
const guardRoutes: RouteRecord[] = [
|
|
432
|
+
{ path: "/", component: Home },
|
|
433
|
+
{ path: "/ok", component: About, beforeEnter: () => true },
|
|
434
|
+
]
|
|
435
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
436
|
+
await router.push("/ok")
|
|
437
|
+
expect(router.currentRoute().path).toBe("/ok")
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test("beforeEnter allows navigation when returning undefined", async () => {
|
|
441
|
+
const guardRoutes: RouteRecord[] = [
|
|
442
|
+
{ path: "/", component: Home },
|
|
443
|
+
{ path: "/ok", component: About, beforeEnter: () => undefined },
|
|
444
|
+
]
|
|
445
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
446
|
+
await router.push("/ok")
|
|
447
|
+
expect(router.currentRoute().path).toBe("/ok")
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test("beforeEnter array — all guards run", async () => {
|
|
451
|
+
const order: string[] = []
|
|
452
|
+
const guardRoutes: RouteRecord[] = [
|
|
453
|
+
{ path: "/", component: Home },
|
|
454
|
+
{
|
|
455
|
+
path: "/multi",
|
|
456
|
+
component: About,
|
|
457
|
+
beforeEnter: [
|
|
458
|
+
() => {
|
|
459
|
+
order.push("g1")
|
|
460
|
+
return true
|
|
461
|
+
},
|
|
462
|
+
() => {
|
|
463
|
+
order.push("g2")
|
|
464
|
+
return true
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
]
|
|
469
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
470
|
+
await router.push("/multi")
|
|
471
|
+
expect(order).toEqual(["g1", "g2"])
|
|
472
|
+
expect(router.currentRoute().path).toBe("/multi")
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test("beforeEnter array — second guard can block", async () => {
|
|
476
|
+
const guardRoutes: RouteRecord[] = [
|
|
477
|
+
{ path: "/", component: Home },
|
|
478
|
+
{
|
|
479
|
+
path: "/blocked",
|
|
480
|
+
component: About,
|
|
481
|
+
beforeEnter: [() => true, () => false],
|
|
482
|
+
},
|
|
483
|
+
]
|
|
484
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
485
|
+
await router.push("/blocked")
|
|
486
|
+
expect(router.currentRoute().path).toBe("/")
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test("beforeEnter that throws is treated as false (blocks)", async () => {
|
|
490
|
+
const guardRoutes: RouteRecord[] = [
|
|
491
|
+
{ path: "/", component: Home },
|
|
492
|
+
{
|
|
493
|
+
path: "/throws",
|
|
494
|
+
component: About,
|
|
495
|
+
beforeEnter: () => {
|
|
496
|
+
throw new Error("guard error")
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
]
|
|
500
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
501
|
+
await router.push("/throws")
|
|
502
|
+
expect(router.currentRoute().path).toBe("/")
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ─── beforeEach (global guard) ───────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
describe("beforeEach global guard", () => {
|
|
509
|
+
test("beforeEach blocks navigation when returning false", async () => {
|
|
510
|
+
const router = createRouter({ routes, url: "/" })
|
|
511
|
+
router.beforeEach(() => false)
|
|
512
|
+
await router.push("/about")
|
|
513
|
+
expect(router.currentRoute().path).toBe("/")
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test("beforeEach redirects when returning a string", async () => {
|
|
517
|
+
const router = createRouter({ routes, url: "/" })
|
|
518
|
+
router.beforeEach((to) => {
|
|
519
|
+
if (to.path === "/about") return "/user/1"
|
|
520
|
+
return true
|
|
521
|
+
})
|
|
522
|
+
await router.push("/about")
|
|
523
|
+
expect(router.currentRoute().path).toBe("/user/1")
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
test("beforeEach allows navigation when returning true", async () => {
|
|
527
|
+
const router = createRouter({ routes, url: "/" })
|
|
528
|
+
router.beforeEach(() => true)
|
|
529
|
+
await router.push("/about")
|
|
530
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test("multiple beforeEach guards run in order", async () => {
|
|
534
|
+
const order: number[] = []
|
|
535
|
+
const router = createRouter({ routes, url: "/" })
|
|
536
|
+
router.beforeEach(() => {
|
|
537
|
+
order.push(1)
|
|
538
|
+
return true
|
|
539
|
+
})
|
|
540
|
+
router.beforeEach(() => {
|
|
541
|
+
order.push(2)
|
|
542
|
+
return true
|
|
543
|
+
})
|
|
544
|
+
await router.push("/about")
|
|
545
|
+
expect(order).toEqual([1, 2])
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test("beforeEach receives to and from", async () => {
|
|
549
|
+
let capturedTo: ResolvedRoute | undefined
|
|
550
|
+
let capturedFrom: ResolvedRoute | undefined
|
|
551
|
+
const router = createRouter({ routes, url: "/" })
|
|
552
|
+
router.beforeEach((to, from) => {
|
|
553
|
+
capturedTo = to
|
|
554
|
+
capturedFrom = from
|
|
555
|
+
return true
|
|
556
|
+
})
|
|
557
|
+
await router.push("/about")
|
|
558
|
+
expect(capturedTo?.path).toBe("/about")
|
|
559
|
+
expect(capturedFrom?.path).toBe("/")
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
// ─── afterEach ───────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
describe("afterEach hook", () => {
|
|
566
|
+
test("afterEach is called after navigation", async () => {
|
|
567
|
+
let called = false
|
|
568
|
+
const router = createRouter({ routes, url: "/" })
|
|
569
|
+
router.afterEach(() => {
|
|
570
|
+
called = true
|
|
571
|
+
})
|
|
572
|
+
await router.push("/about")
|
|
573
|
+
expect(called).toBe(true)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test("afterEach receives to and from", async () => {
|
|
577
|
+
let capturedTo: ResolvedRoute | undefined
|
|
578
|
+
let capturedFrom: ResolvedRoute | undefined
|
|
579
|
+
const router = createRouter({ routes, url: "/" })
|
|
580
|
+
router.afterEach((to, from) => {
|
|
581
|
+
capturedTo = to
|
|
582
|
+
capturedFrom = from
|
|
583
|
+
})
|
|
584
|
+
await router.push("/about")
|
|
585
|
+
expect(capturedTo?.path).toBe("/about")
|
|
586
|
+
expect(capturedFrom?.path).toBe("/")
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test("afterEach errors are caught and do not break navigation", async () => {
|
|
590
|
+
const router = createRouter({ routes, url: "/" })
|
|
591
|
+
router.afterEach(() => {
|
|
592
|
+
throw new Error("hook error")
|
|
593
|
+
})
|
|
594
|
+
await router.push("/about")
|
|
595
|
+
// Navigation should still succeed
|
|
596
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test("multiple afterEach hooks all run", async () => {
|
|
600
|
+
const calls: number[] = []
|
|
601
|
+
const router = createRouter({ routes, url: "/" })
|
|
602
|
+
router.afterEach(() => {
|
|
603
|
+
calls.push(1)
|
|
604
|
+
})
|
|
605
|
+
router.afterEach(() => {
|
|
606
|
+
calls.push(2)
|
|
607
|
+
})
|
|
608
|
+
await router.push("/about")
|
|
609
|
+
expect(calls).toEqual([1, 2])
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
// ─── beforeLeave guard ────────────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
describe("beforeLeave guard", () => {
|
|
616
|
+
test("beforeLeave can cancel navigation", async () => {
|
|
617
|
+
const leaveRoutes: RouteRecord[] = [
|
|
618
|
+
{ path: "/a", component: Home, beforeLeave: () => false },
|
|
619
|
+
{ path: "/b", component: User },
|
|
620
|
+
]
|
|
621
|
+
const router = createRouter({ routes: leaveRoutes, url: "/a" })
|
|
622
|
+
await router.push("/b")
|
|
623
|
+
expect(router.currentRoute().path).toBe("/a")
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test("beforeLeave runs before entering new route", async () => {
|
|
627
|
+
const order: string[] = []
|
|
628
|
+
const leaveRoutes: RouteRecord[] = [
|
|
629
|
+
{
|
|
630
|
+
path: "/a",
|
|
631
|
+
component: Home,
|
|
632
|
+
beforeLeave: () => {
|
|
633
|
+
order.push("leave-a")
|
|
634
|
+
return true
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
path: "/b",
|
|
639
|
+
component: User,
|
|
640
|
+
beforeEnter: () => {
|
|
641
|
+
order.push("enter-b")
|
|
642
|
+
return true
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
]
|
|
646
|
+
const router = createRouter({ routes: leaveRoutes, url: "/a" })
|
|
647
|
+
await router.push("/b")
|
|
648
|
+
expect(order).toEqual(["leave-a", "enter-b"])
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
test("beforeLeave can redirect", async () => {
|
|
652
|
+
const leaveRoutes: RouteRecord[] = [
|
|
653
|
+
{ path: "/a", component: Home, beforeLeave: (to) => (to.path === "/b" ? "/c" : undefined) },
|
|
654
|
+
{ path: "/b", component: User },
|
|
655
|
+
{ path: "/c", component: Home },
|
|
656
|
+
]
|
|
657
|
+
const router = createRouter({ routes: leaveRoutes, url: "/a" })
|
|
658
|
+
await router.push("/b")
|
|
659
|
+
expect(router.currentRoute().path).toBe("/c")
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
test("beforeLeave array — all guards run", async () => {
|
|
663
|
+
const order: string[] = []
|
|
664
|
+
const leaveRoutes: RouteRecord[] = [
|
|
665
|
+
{
|
|
666
|
+
path: "/a",
|
|
667
|
+
component: Home,
|
|
668
|
+
beforeLeave: [
|
|
669
|
+
() => {
|
|
670
|
+
order.push("g1")
|
|
671
|
+
return true
|
|
672
|
+
},
|
|
673
|
+
() => {
|
|
674
|
+
order.push("g2")
|
|
675
|
+
return true
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
},
|
|
679
|
+
{ path: "/b", component: User },
|
|
680
|
+
]
|
|
681
|
+
const router = createRouter({ routes: leaveRoutes, url: "/a" })
|
|
682
|
+
await router.push("/b")
|
|
683
|
+
expect(order).toEqual(["g1", "g2"])
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
test("beforeLeave array — second guard can block", async () => {
|
|
687
|
+
const leaveRoutes: RouteRecord[] = [
|
|
688
|
+
{
|
|
689
|
+
path: "/a",
|
|
690
|
+
component: Home,
|
|
691
|
+
beforeLeave: [() => true, () => false],
|
|
692
|
+
},
|
|
693
|
+
{ path: "/b", component: User },
|
|
694
|
+
]
|
|
695
|
+
const router = createRouter({ routes: leaveRoutes, url: "/a" })
|
|
696
|
+
await router.push("/b")
|
|
697
|
+
expect(router.currentRoute().path).toBe("/a")
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
// ─── Redirect routes ─────────────────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
describe("redirect routes", () => {
|
|
704
|
+
test("static redirect", async () => {
|
|
705
|
+
const redirectRoutes: RouteRecord[] = [
|
|
706
|
+
{ path: "/", component: Home },
|
|
707
|
+
{ path: "/old", component: About, redirect: "/new" },
|
|
708
|
+
{ path: "/new", component: User },
|
|
709
|
+
]
|
|
710
|
+
const router = createRouter({ routes: redirectRoutes, url: "/" })
|
|
711
|
+
await router.push("/old")
|
|
712
|
+
expect(router.currentRoute().path).toBe("/new")
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test("dynamic redirect (function)", async () => {
|
|
716
|
+
const redirectRoutes: RouteRecord[] = [
|
|
717
|
+
{ path: "/", component: Home },
|
|
718
|
+
{
|
|
719
|
+
path: "/old/:id",
|
|
720
|
+
component: About,
|
|
721
|
+
redirect: (to) => `/new/${to.params.id}`,
|
|
722
|
+
},
|
|
723
|
+
{ path: "/new/:id", component: User },
|
|
724
|
+
]
|
|
725
|
+
const router = createRouter({ routes: redirectRoutes, url: "/" })
|
|
726
|
+
await router.push("/old/42")
|
|
727
|
+
expect(router.currentRoute().path).toBe("/new/42")
|
|
728
|
+
})
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// ─── loading signal ───────────────────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
describe("router.loading", () => {
|
|
734
|
+
test("is false when no navigation is in progress", () => {
|
|
735
|
+
const router = createRouter({ routes: [{ path: "/", component: Home }], url: "/" })
|
|
736
|
+
expect(router.loading()).toBe(false)
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
test("is true during async guard execution", async () => {
|
|
740
|
+
let loadingWhileGuard = false
|
|
741
|
+
const guardRoutes: RouteRecord[] = [
|
|
742
|
+
{ path: "/a", component: Home },
|
|
743
|
+
{
|
|
744
|
+
path: "/b",
|
|
745
|
+
component: User,
|
|
746
|
+
beforeEnter: async () => {
|
|
747
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
748
|
+
return true
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
]
|
|
752
|
+
const router = createRouter({ routes: guardRoutes, url: "/a" })
|
|
753
|
+
const nav = router.push("/b")
|
|
754
|
+
await new Promise<void>((r) => setTimeout(r, 1))
|
|
755
|
+
loadingWhileGuard = router.loading()
|
|
756
|
+
await nav
|
|
757
|
+
expect(loadingWhileGuard).toBe(true)
|
|
758
|
+
expect(router.loading()).toBe(false)
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
test("is true during loader execution", async () => {
|
|
762
|
+
let loadingWhileLoader = false
|
|
763
|
+
const loaderRoutes: RouteRecord[] = [
|
|
764
|
+
{ path: "/", component: Home },
|
|
765
|
+
{
|
|
766
|
+
path: "/data",
|
|
767
|
+
component: About,
|
|
768
|
+
loader: async () => {
|
|
769
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
770
|
+
return "data"
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
]
|
|
774
|
+
const router = createRouter({ routes: loaderRoutes, url: "/" })
|
|
775
|
+
const nav = router.push("/data")
|
|
776
|
+
await new Promise<void>((r) => setTimeout(r, 1))
|
|
777
|
+
loadingWhileLoader = router.loading()
|
|
778
|
+
await nav
|
|
779
|
+
expect(loadingWhileLoader).toBe(true)
|
|
780
|
+
expect(router.loading()).toBe(false)
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
// ─── Circular redirect detection ─────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
describe("circular redirect detection", () => {
|
|
787
|
+
test("aborts after 10 levels of redirect instead of infinite looping", async () => {
|
|
788
|
+
const circRoutes: RouteRecord[] = [
|
|
789
|
+
{ path: "/a", component: Home, redirect: "/b" },
|
|
790
|
+
{ path: "/b", component: Home, redirect: "/a" },
|
|
791
|
+
]
|
|
792
|
+
const router = createRouter({ routes: circRoutes, url: "/" })
|
|
793
|
+
await router.push("/a")
|
|
794
|
+
expect(router.currentRoute().path).toBe("/")
|
|
795
|
+
})
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
// ─── Guard execution order ───────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
describe("guard execution order", () => {
|
|
801
|
+
test("order: beforeLeave → beforeEnter → beforeEach → afterEach", async () => {
|
|
802
|
+
const order: string[] = []
|
|
803
|
+
const orderedRoutes: RouteRecord[] = [
|
|
804
|
+
{
|
|
805
|
+
path: "/a",
|
|
806
|
+
component: Home,
|
|
807
|
+
beforeLeave: () => {
|
|
808
|
+
order.push("beforeLeave")
|
|
809
|
+
return true
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
path: "/b",
|
|
814
|
+
component: User,
|
|
815
|
+
beforeEnter: () => {
|
|
816
|
+
order.push("beforeEnter")
|
|
817
|
+
return true
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
]
|
|
821
|
+
const router = createRouter({ routes: orderedRoutes, url: "/a" })
|
|
822
|
+
router.beforeEach(() => {
|
|
823
|
+
order.push("beforeEach")
|
|
824
|
+
return true
|
|
825
|
+
})
|
|
826
|
+
router.afterEach(() => {
|
|
827
|
+
order.push("afterEach")
|
|
828
|
+
})
|
|
829
|
+
await router.push("/b")
|
|
830
|
+
expect(order).toEqual(["beforeLeave", "beforeEnter", "beforeEach", "afterEach"])
|
|
831
|
+
})
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
// ─── Navigation generation (stale guard cancellation) ────────────────────────
|
|
835
|
+
|
|
836
|
+
describe("navigation generation counter", () => {
|
|
837
|
+
test("stale async guard does not commit navigation", async () => {
|
|
838
|
+
const guardRoutes: RouteRecord[] = [
|
|
839
|
+
{ path: "/", component: Home },
|
|
840
|
+
{
|
|
841
|
+
path: "/slow",
|
|
842
|
+
component: About,
|
|
843
|
+
beforeEnter: async () => {
|
|
844
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
845
|
+
return true
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
{ path: "/fast", component: User },
|
|
849
|
+
]
|
|
850
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
851
|
+
|
|
852
|
+
// Start slow navigation, then immediately start fast one
|
|
853
|
+
const slow = router.push("/slow")
|
|
854
|
+
await router.push("/fast")
|
|
855
|
+
await slow
|
|
856
|
+
|
|
857
|
+
// Final route should be /fast, not /slow
|
|
858
|
+
expect(router.currentRoute().path).toBe("/fast")
|
|
859
|
+
})
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
// ─── Loader data cleanup ─────────────────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
describe("loader data cleanup", () => {
|
|
865
|
+
test("prunes loader data for routes no longer matched", async () => {
|
|
866
|
+
const cleanupRoutes: RouteRecord[] = [
|
|
867
|
+
{ path: "/a", component: Home, loader: async () => "a-data" },
|
|
868
|
+
{ path: "/b", component: About },
|
|
869
|
+
]
|
|
870
|
+
const router = createRouter({ routes: cleanupRoutes, url: "/" }) as RouterInstance
|
|
871
|
+
await router.push("/a")
|
|
872
|
+
expect(router._loaderData.size).toBe(1)
|
|
873
|
+
await router.push("/b")
|
|
874
|
+
expect(router._loaderData.size).toBe(0)
|
|
875
|
+
})
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
// ─── Lazy routes ─────────────────────────────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
describe("lazy routes", () => {
|
|
881
|
+
test("lazy() creates a lazy component marker", () => {
|
|
882
|
+
const lazyComp = lazy(() => Promise.resolve(Home))
|
|
883
|
+
expect(typeof lazyComp).toBe("object")
|
|
884
|
+
expect(lazyComp.loader).toBeDefined()
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
test("lazy() accepts loading and error components", () => {
|
|
888
|
+
const Loading = () => null
|
|
889
|
+
const ErrorComp = () => null
|
|
890
|
+
const lazyComp = lazy(() => Promise.resolve(Home), { loading: Loading, error: ErrorComp })
|
|
891
|
+
expect(lazyComp.loadingComponent).toBe(Loading)
|
|
892
|
+
expect(lazyComp.errorComponent).toBe(ErrorComp)
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
test("isLazy identifies lazy components", async () => {
|
|
896
|
+
const { isLazy } = await import("../types")
|
|
897
|
+
const lazyComp = lazy(() => Promise.resolve(Home))
|
|
898
|
+
expect(isLazy(lazyComp)).toBe(true)
|
|
899
|
+
expect(isLazy(Home)).toBe(false)
|
|
900
|
+
})
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
// ─── Route loaders ────────────────────────────────────────────────────────────
|
|
904
|
+
|
|
905
|
+
describe("route loaders — prefetchLoaderData", () => {
|
|
906
|
+
test("runs loader and stores result by record", async () => {
|
|
907
|
+
const loaderRoutes: RouteRecord[] = [
|
|
908
|
+
{ path: "/", component: Home },
|
|
909
|
+
{ path: "/data", component: About, loader: async () => ({ title: "hello" }) },
|
|
910
|
+
]
|
|
911
|
+
const router = createRouter(loaderRoutes) as RouterInstance
|
|
912
|
+
await prefetchLoaderData(router, "/data")
|
|
913
|
+
|
|
914
|
+
const values = Array.from(router._loaderData.values())
|
|
915
|
+
expect(values).toHaveLength(1)
|
|
916
|
+
expect(values[0]).toEqual({ title: "hello" })
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
test("passes params and query to loader", async () => {
|
|
920
|
+
let captured: { params: Record<string, string>; query: Record<string, string> } | null = null
|
|
921
|
+
const loaderRoutes: RouteRecord[] = [
|
|
922
|
+
{
|
|
923
|
+
path: "/user/:id",
|
|
924
|
+
component: User,
|
|
925
|
+
loader: async ({ params, query }) => {
|
|
926
|
+
captured = { params, query }
|
|
927
|
+
return null
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
]
|
|
931
|
+
const router = createRouter(loaderRoutes) as RouterInstance
|
|
932
|
+
await prefetchLoaderData(router, "/user/42?tab=profile")
|
|
933
|
+
|
|
934
|
+
if (!captured) throw new Error("expected captured")
|
|
935
|
+
expect((captured as { params: Record<string, string> }).params.id).toBe("42")
|
|
936
|
+
expect((captured as { query: Record<string, string> }).query.tab).toBe("profile")
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
test("multiple loaders on matched records run in parallel", async () => {
|
|
940
|
+
const order: string[] = []
|
|
941
|
+
const parentRoutes: RouteRecord[] = [
|
|
942
|
+
{
|
|
943
|
+
path: "/admin",
|
|
944
|
+
component: Home,
|
|
945
|
+
loader: async () => {
|
|
946
|
+
order.push("parent")
|
|
947
|
+
return "parent-data"
|
|
948
|
+
},
|
|
949
|
+
children: [
|
|
950
|
+
{
|
|
951
|
+
path: "users",
|
|
952
|
+
component: About,
|
|
953
|
+
loader: async () => {
|
|
954
|
+
order.push("child")
|
|
955
|
+
return "child-data"
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
},
|
|
960
|
+
]
|
|
961
|
+
const router = createRouter(parentRoutes) as RouterInstance
|
|
962
|
+
await prefetchLoaderData(router, "/admin/users")
|
|
963
|
+
|
|
964
|
+
expect(order).toContain("parent")
|
|
965
|
+
expect(order).toContain("child")
|
|
966
|
+
expect(router._loaderData.size).toBe(2)
|
|
967
|
+
})
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
describe("route loaders — serializeLoaderData / hydrateLoaderData", () => {
|
|
971
|
+
test("serializeLoaderData returns path-keyed object", async () => {
|
|
972
|
+
const loaderRoutes: RouteRecord[] = [
|
|
973
|
+
{ path: "/items", component: Home, loader: async () => [1, 2, 3] },
|
|
974
|
+
]
|
|
975
|
+
const router = createRouter(loaderRoutes) as RouterInstance
|
|
976
|
+
await prefetchLoaderData(router, "/items")
|
|
977
|
+
|
|
978
|
+
const serialized = serializeLoaderData(router)
|
|
979
|
+
expect(serialized["/items"]).toEqual([1, 2, 3])
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
test("hydrateLoaderData populates _loaderData for current route", () => {
|
|
983
|
+
const loaderRoutes: RouteRecord[] = [
|
|
984
|
+
{ path: "/", component: Home },
|
|
985
|
+
{ path: "/items", component: About, loader: async () => [] },
|
|
986
|
+
]
|
|
987
|
+
const router = createRouter({ routes: loaderRoutes, url: "/items" }) as RouterInstance
|
|
988
|
+
hydrateLoaderData(router, { "/items": ["a", "b"] })
|
|
989
|
+
|
|
990
|
+
const values = Array.from(router._loaderData.values())
|
|
991
|
+
expect(values).toHaveLength(1)
|
|
992
|
+
expect(values[0]).toEqual(["a", "b"])
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
test("serialize → hydrate round-trip preserves data", async () => {
|
|
996
|
+
const loaderRoutes: RouteRecord[] = [
|
|
997
|
+
{ path: "/page", component: Home, loader: async () => ({ x: 1 }) },
|
|
998
|
+
]
|
|
999
|
+
const ssrRouter = createRouter({ routes: loaderRoutes, url: "/page" }) as RouterInstance
|
|
1000
|
+
await prefetchLoaderData(ssrRouter, "/page")
|
|
1001
|
+
const serialized = serializeLoaderData(ssrRouter)
|
|
1002
|
+
|
|
1003
|
+
const clientRouter = createRouter({ routes: loaderRoutes, url: "/page" }) as RouterInstance
|
|
1004
|
+
hydrateLoaderData(clientRouter, serialized)
|
|
1005
|
+
|
|
1006
|
+
const values = Array.from(clientRouter._loaderData.values())
|
|
1007
|
+
expect(values[0]).toEqual({ x: 1 })
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
test("hydrateLoaderData handles null/undefined gracefully", () => {
|
|
1011
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
1012
|
+
expect(() =>
|
|
1013
|
+
hydrateLoaderData(router, null as unknown as Record<string, unknown>),
|
|
1014
|
+
).not.toThrow()
|
|
1015
|
+
expect(() =>
|
|
1016
|
+
hydrateLoaderData(router, undefined as unknown as Record<string, unknown>),
|
|
1017
|
+
).not.toThrow()
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
test("loader is aborted when navigation is superseded", async () => {
|
|
1021
|
+
let capturedSignal: AbortSignal | undefined
|
|
1022
|
+
let slowStarted = false
|
|
1023
|
+
|
|
1024
|
+
const loaderRoutes: RouteRecord[] = [
|
|
1025
|
+
{ path: "/", component: Home },
|
|
1026
|
+
{
|
|
1027
|
+
path: "/slow",
|
|
1028
|
+
component: About,
|
|
1029
|
+
loader: async ({ signal }) => {
|
|
1030
|
+
capturedSignal = signal
|
|
1031
|
+
slowStarted = true
|
|
1032
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
1033
|
+
return "slow"
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
{ path: "/fast", component: User, loader: async () => "fast" },
|
|
1037
|
+
]
|
|
1038
|
+
const router = createRouter(loaderRoutes) as RouterInstance
|
|
1039
|
+
|
|
1040
|
+
const slowNav = router.push("/slow")
|
|
1041
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
1042
|
+
expect(slowStarted).toBe(true)
|
|
1043
|
+
|
|
1044
|
+
await router.push("/fast")
|
|
1045
|
+
await slowNav
|
|
1046
|
+
|
|
1047
|
+
expect(capturedSignal?.aborted).toBe(true)
|
|
1048
|
+
expect(router.currentRoute().path).toBe("/fast")
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
test("loader error is caught gracefully", async () => {
|
|
1052
|
+
const loaderRoutes: RouteRecord[] = [
|
|
1053
|
+
{ path: "/", component: Home },
|
|
1054
|
+
{
|
|
1055
|
+
path: "/fail",
|
|
1056
|
+
component: About,
|
|
1057
|
+
loader: async () => {
|
|
1058
|
+
throw new Error("loader failed")
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
]
|
|
1062
|
+
const router = createRouter({ routes: loaderRoutes, url: "/" })
|
|
1063
|
+
// Should not throw
|
|
1064
|
+
await router.push("/fail")
|
|
1065
|
+
expect(router.currentRoute().path).toBe("/fail")
|
|
1066
|
+
})
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
// ─── ScrollManager ───────────────────────────────────────────────────────────
|
|
1070
|
+
|
|
1071
|
+
describe("ScrollManager", () => {
|
|
1072
|
+
test("constructor defaults to 'top' behavior", () => {
|
|
1073
|
+
const sm = new ScrollManager()
|
|
1074
|
+
expect(sm.getSavedPosition("/test")).toBeNull()
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
test("constructor accepts custom behavior", () => {
|
|
1078
|
+
const sm = new ScrollManager("restore")
|
|
1079
|
+
expect(sm).toBeDefined()
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
test("getSavedPosition returns null for unsaved path", () => {
|
|
1083
|
+
const sm = new ScrollManager()
|
|
1084
|
+
expect(sm.getSavedPosition("/unknown")).toBeNull()
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
test("save does not throw in SSR (no window)", () => {
|
|
1088
|
+
// In bun test there is no real window.scrollY, but save should not throw
|
|
1089
|
+
const sm = new ScrollManager()
|
|
1090
|
+
expect(() => sm.save("/page")).not.toThrow()
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
test("restore does not throw in SSR (no window check)", () => {
|
|
1094
|
+
const sm = new ScrollManager()
|
|
1095
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
1096
|
+
const from: ResolvedRoute = {
|
|
1097
|
+
path: "/b",
|
|
1098
|
+
params: {},
|
|
1099
|
+
query: {},
|
|
1100
|
+
hash: "",
|
|
1101
|
+
matched: [],
|
|
1102
|
+
meta: {},
|
|
1103
|
+
}
|
|
1104
|
+
expect(() => sm.restore(to, from)).not.toThrow()
|
|
1105
|
+
})
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
// ─── Router internal state ───────────────────────────────────────────────────
|
|
1109
|
+
|
|
1110
|
+
describe("router internal state", () => {
|
|
1111
|
+
test("_viewDepth starts at 0", () => {
|
|
1112
|
+
const router = createRouter(routes) as RouterInstance
|
|
1113
|
+
expect(router._viewDepth).toBe(0)
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
test("_componentCache is initially empty", () => {
|
|
1117
|
+
const router = createRouter(routes) as RouterInstance
|
|
1118
|
+
expect(router._componentCache.size).toBe(0)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
test("_erroredChunks is initially empty", () => {
|
|
1122
|
+
const router = createRouter(routes) as RouterInstance
|
|
1123
|
+
expect(router._erroredChunks.size).toBe(0)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
test("_resolve exposes internal route resolution", () => {
|
|
1127
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
1128
|
+
const resolved = router._resolve("/about")
|
|
1129
|
+
expect(resolved.path).toBe("/about")
|
|
1130
|
+
expect(resolved.matched.length).toBeGreaterThan(0)
|
|
1131
|
+
})
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
// ─── Helper: create a fresh container ─────────────────────────────────────────
|
|
1135
|
+
|
|
1136
|
+
function container(): HTMLElement {
|
|
1137
|
+
const el = document.createElement("div")
|
|
1138
|
+
document.body.appendChild(el)
|
|
1139
|
+
return el
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ─── getActiveRouter / setActiveRouter ────────────────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
describe("getActiveRouter / setActiveRouter", () => {
|
|
1145
|
+
beforeEach(() => {
|
|
1146
|
+
setActiveRouter(null)
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
test("getActiveRouter returns null initially", () => {
|
|
1150
|
+
expect(getActiveRouter()).toBeNull()
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
test("setActiveRouter sets and getActiveRouter retrieves", () => {
|
|
1154
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
1155
|
+
setActiveRouter(router)
|
|
1156
|
+
expect(getActiveRouter()).toBe(router)
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
test("setActiveRouter resets _viewDepth to 0", () => {
|
|
1160
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
1161
|
+
router._viewDepth = 5
|
|
1162
|
+
setActiveRouter(router)
|
|
1163
|
+
expect(router._viewDepth).toBe(0)
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
test("setActiveRouter(null) clears active router", () => {
|
|
1167
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
1168
|
+
setActiveRouter(router)
|
|
1169
|
+
setActiveRouter(null)
|
|
1170
|
+
expect(getActiveRouter()).toBeNull()
|
|
1171
|
+
})
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
// ─── useRouter / useRoute (outside component tree) ───────────────────────────
|
|
1175
|
+
|
|
1176
|
+
describe("useRouter / useRoute", () => {
|
|
1177
|
+
beforeEach(() => {
|
|
1178
|
+
setActiveRouter(null)
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
test("useRouter throws when no router installed", () => {
|
|
1182
|
+
expect(() => useRouter()).toThrow("[pyreon-router] No router installed")
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
test("useRoute throws when no router installed", () => {
|
|
1186
|
+
expect(() => useRoute()).toThrow("[pyreon-router] No router installed")
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
test("useRouter returns router after setActiveRouter", () => {
|
|
1190
|
+
const router = createRouter({ routes, url: "/" })
|
|
1191
|
+
setActiveRouter(router as RouterInstance)
|
|
1192
|
+
expect(useRouter()).toBe(router)
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
test("useRoute returns currentRoute accessor after setActiveRouter", () => {
|
|
1196
|
+
const router = createRouter({ routes, url: "/about" })
|
|
1197
|
+
setActiveRouter(router as RouterInstance)
|
|
1198
|
+
const route = useRoute()
|
|
1199
|
+
expect(route().path).toBe("/about")
|
|
1200
|
+
})
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
// ─── RouterProvider ──────────────────────────────────────────────────────────
|
|
1204
|
+
|
|
1205
|
+
describe("RouterProvider", () => {
|
|
1206
|
+
beforeEach(() => {
|
|
1207
|
+
setActiveRouter(null)
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
test("renders children", () => {
|
|
1211
|
+
const el = container()
|
|
1212
|
+
const router = createRouter({ routes, url: "/" })
|
|
1213
|
+
mount(h(RouterProvider, { router }, h("span", null, "child-content")), el)
|
|
1214
|
+
expect(el.textContent).toContain("child-content")
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
test("sets active router for useRouter calls", () => {
|
|
1218
|
+
const el = container()
|
|
1219
|
+
const router = createRouter({ routes, url: "/" })
|
|
1220
|
+
let capturedRouter: unknown = null
|
|
1221
|
+
const Checker = () => {
|
|
1222
|
+
capturedRouter = useRouter()
|
|
1223
|
+
return null
|
|
1224
|
+
}
|
|
1225
|
+
mount(h(RouterProvider, { router }, h(Checker, null)), el)
|
|
1226
|
+
expect(capturedRouter).toBe(router)
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
test("renders null children gracefully", () => {
|
|
1230
|
+
const el = container()
|
|
1231
|
+
const router = createRouter({ routes, url: "/" })
|
|
1232
|
+
mount(h(RouterProvider, { router }), el)
|
|
1233
|
+
// Should not throw, el may have empty or comment content
|
|
1234
|
+
expect(el).toBeDefined()
|
|
1235
|
+
})
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
// ─── RouterView ──────────────────────────────────────────────────────────────
|
|
1239
|
+
|
|
1240
|
+
describe("RouterView", () => {
|
|
1241
|
+
beforeEach(() => {
|
|
1242
|
+
setActiveRouter(null)
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
test("returns null when no router is available", () => {
|
|
1246
|
+
const el = container()
|
|
1247
|
+
mount(h(RouterView, {}), el)
|
|
1248
|
+
// RouterView always wraps in a div, but with no router the child is null
|
|
1249
|
+
const wrapper = el.querySelector("[data-pyreon-router-view]")
|
|
1250
|
+
expect(wrapper).not.toBeNull()
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
test("renders matched route component at depth 0", () => {
|
|
1254
|
+
const el = container()
|
|
1255
|
+
const HomePage = () => h("span", null, "home-page")
|
|
1256
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: HomePage }]
|
|
1257
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1258
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1259
|
+
expect(el.textContent).toContain("home-page")
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
test("renders nothing when no matched routes", () => {
|
|
1263
|
+
const el = container()
|
|
1264
|
+
const viewRoutes: RouteRecord[] = [
|
|
1265
|
+
{ path: "/specific", component: () => h("span", null, "specific") },
|
|
1266
|
+
]
|
|
1267
|
+
const router = createRouter({ routes: viewRoutes, url: "/nonexistent" })
|
|
1268
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1269
|
+
// The wrapper div exists but should have no rendered component content
|
|
1270
|
+
const text = el.textContent ?? ""
|
|
1271
|
+
expect(text).not.toContain("specific")
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
test("renders nested routes at correct depth", () => {
|
|
1275
|
+
const el = container()
|
|
1276
|
+
const ChildComp = () => h("span", null, "child-content")
|
|
1277
|
+
const ParentComp = () => h("div", { class: "parent" }, h(RouterView, {}))
|
|
1278
|
+
const nestedViewRoutes: RouteRecord[] = [
|
|
1279
|
+
{
|
|
1280
|
+
path: "/parent",
|
|
1281
|
+
component: ParentComp,
|
|
1282
|
+
children: [{ path: "child", component: ChildComp }],
|
|
1283
|
+
},
|
|
1284
|
+
]
|
|
1285
|
+
const router = createRouter({ routes: nestedViewRoutes, url: "/parent/child" })
|
|
1286
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1287
|
+
expect(el.textContent).toContain("child-content")
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
test("accepts explicit router prop", () => {
|
|
1291
|
+
const el = container()
|
|
1292
|
+
const TestComp = () => h("span", null, "explicit-router")
|
|
1293
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: TestComp }]
|
|
1294
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1295
|
+
// Pass router directly via prop instead of context
|
|
1296
|
+
mount(h(RouterView, { router }), el)
|
|
1297
|
+
expect(el.textContent).toContain("explicit-router")
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
test("renders component with route props (params, query, meta)", () => {
|
|
1301
|
+
const el = container()
|
|
1302
|
+
let capturedProps: Record<string, unknown> = {}
|
|
1303
|
+
const PropsComp = (props: Record<string, unknown>) => {
|
|
1304
|
+
capturedProps = props
|
|
1305
|
+
return h("span", null, "props-test")
|
|
1306
|
+
}
|
|
1307
|
+
const viewRoutes: RouteRecord[] = [
|
|
1308
|
+
{ path: "/user/:id", component: PropsComp, meta: { title: "User" } },
|
|
1309
|
+
]
|
|
1310
|
+
const router = createRouter({ routes: viewRoutes, url: "/user/42?tab=profile" })
|
|
1311
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1312
|
+
expect(capturedProps.params).toEqual({ id: "42" })
|
|
1313
|
+
expect((capturedProps.query as Record<string, string>).tab).toBe("profile")
|
|
1314
|
+
expect((capturedProps.meta as Record<string, string>).title).toBe("User")
|
|
1315
|
+
})
|
|
1316
|
+
|
|
1317
|
+
test("renders loader data via LoaderDataProvider", async () => {
|
|
1318
|
+
const el = container()
|
|
1319
|
+
const DataComp = () => {
|
|
1320
|
+
return h("span", null, "data-comp")
|
|
1321
|
+
}
|
|
1322
|
+
const viewRoutes: RouteRecord[] = [
|
|
1323
|
+
{
|
|
1324
|
+
path: "/data",
|
|
1325
|
+
component: DataComp,
|
|
1326
|
+
loader: async () => ({ items: [1, 2, 3] }),
|
|
1327
|
+
},
|
|
1328
|
+
]
|
|
1329
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1330
|
+
// Prefetch to populate loader data
|
|
1331
|
+
await prefetchLoaderData(router as RouterInstance, "/data")
|
|
1332
|
+
|
|
1333
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1334
|
+
// Navigate to the data route
|
|
1335
|
+
await router.push("/data")
|
|
1336
|
+
// The component should render
|
|
1337
|
+
expect(el.textContent).toContain("data-comp")
|
|
1338
|
+
})
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
// ─── RouterLink ──────────────────────────────────────────────────────────────
|
|
1342
|
+
|
|
1343
|
+
describe("RouterLink", () => {
|
|
1344
|
+
beforeEach(() => {
|
|
1345
|
+
setActiveRouter(null)
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
test("renders an <a> tag with correct href (hash mode)", () => {
|
|
1349
|
+
const el = container()
|
|
1350
|
+
const router = createRouter({ routes, url: "/" })
|
|
1351
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/about" }, "About")), el)
|
|
1352
|
+
const anchor = el.querySelector("a")
|
|
1353
|
+
expect(anchor).not.toBeNull()
|
|
1354
|
+
expect(anchor?.getAttribute("href")).toBe("#/about")
|
|
1355
|
+
expect(anchor?.textContent).toBe("About")
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
test("renders an <a> tag with correct href (history mode)", () => {
|
|
1359
|
+
const el = container()
|
|
1360
|
+
const router = createRouter({ routes, mode: "history", url: "/" })
|
|
1361
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/about" }, "About")), el)
|
|
1362
|
+
const anchor = el.querySelector("a")
|
|
1363
|
+
expect(anchor?.getAttribute("href")).toBe("/about")
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
test("uses to as default children text", () => {
|
|
1367
|
+
const el = container()
|
|
1368
|
+
const router = createRouter({ routes, url: "/" })
|
|
1369
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/about" })), el)
|
|
1370
|
+
const anchor = el.querySelector("a")
|
|
1371
|
+
expect(anchor?.textContent).toBe("/about")
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
test("applies active class when route matches", () => {
|
|
1375
|
+
const el = container()
|
|
1376
|
+
const router = createRouter({ routes, url: "/about" })
|
|
1377
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/about" }, "About")), el)
|
|
1378
|
+
const anchor = el.querySelector("a")
|
|
1379
|
+
const cls = anchor?.getAttribute("class") ?? ""
|
|
1380
|
+
expect(cls).toContain("router-link-active")
|
|
1381
|
+
expect(cls).toContain("router-link-exact-active")
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
test("applies custom activeClass and exactActiveClass", () => {
|
|
1385
|
+
const el = container()
|
|
1386
|
+
const router = createRouter({ routes, url: "/about" })
|
|
1387
|
+
mount(
|
|
1388
|
+
h(
|
|
1389
|
+
RouterProvider,
|
|
1390
|
+
{ router },
|
|
1391
|
+
h(
|
|
1392
|
+
RouterLink,
|
|
1393
|
+
{ to: "/about", activeClass: "my-active", exactActiveClass: "my-exact" },
|
|
1394
|
+
"About",
|
|
1395
|
+
),
|
|
1396
|
+
),
|
|
1397
|
+
el,
|
|
1398
|
+
)
|
|
1399
|
+
const anchor = el.querySelector("a")
|
|
1400
|
+
const cls = anchor?.getAttribute("class") ?? ""
|
|
1401
|
+
expect(cls).toContain("my-active")
|
|
1402
|
+
expect(cls).toContain("my-exact")
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
test("applies active class for prefix match on nested paths", () => {
|
|
1406
|
+
const el = container()
|
|
1407
|
+
const nestedRoutes: RouteRecord[] = [
|
|
1408
|
+
{ path: "/admin", component: Home, children: [{ path: "users", component: About }] },
|
|
1409
|
+
]
|
|
1410
|
+
const router = createRouter({ routes: nestedRoutes, url: "/admin/users" })
|
|
1411
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/admin" }, "Admin")), el)
|
|
1412
|
+
const anchor = el.querySelector("a")
|
|
1413
|
+
const cls = anchor?.getAttribute("class") ?? ""
|
|
1414
|
+
expect(cls).toContain("router-link-active")
|
|
1415
|
+
// Not exact match
|
|
1416
|
+
expect(cls).not.toContain("router-link-exact-active")
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
test("exact prop prevents prefix matching", () => {
|
|
1420
|
+
const el = container()
|
|
1421
|
+
const nestedRoutes: RouteRecord[] = [
|
|
1422
|
+
{ path: "/admin", component: Home, children: [{ path: "users", component: About }] },
|
|
1423
|
+
]
|
|
1424
|
+
const router = createRouter({ routes: nestedRoutes, url: "/admin/users" })
|
|
1425
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/admin", exact: true }, "Admin")), el)
|
|
1426
|
+
const anchor = el.querySelector("a")
|
|
1427
|
+
const cls = anchor?.getAttribute("class") ?? ""
|
|
1428
|
+
expect(cls).not.toContain("router-link-active")
|
|
1429
|
+
})
|
|
1430
|
+
|
|
1431
|
+
test("root path / does not match all paths as prefix", () => {
|
|
1432
|
+
const el = container()
|
|
1433
|
+
const router = createRouter({ routes, url: "/about" })
|
|
1434
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/" }, "Home")), el)
|
|
1435
|
+
const anchor = el.querySelector("a")
|
|
1436
|
+
const cls = anchor?.getAttribute("class") ?? ""
|
|
1437
|
+
// "/" should not be active for "/about"
|
|
1438
|
+
expect(cls).not.toContain("router-link-active")
|
|
1439
|
+
})
|
|
1440
|
+
|
|
1441
|
+
test("click triggers router.push", async () => {
|
|
1442
|
+
const el = container()
|
|
1443
|
+
const router = createRouter({ routes, url: "/" })
|
|
1444
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/about" }, "About")), el)
|
|
1445
|
+
const anchor = el.querySelector("a")
|
|
1446
|
+
expect(anchor).not.toBeNull()
|
|
1447
|
+
// Simulate click
|
|
1448
|
+
const event = new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
1449
|
+
anchor?.dispatchEvent(event)
|
|
1450
|
+
// Wait for async navigation
|
|
1451
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
1452
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
test("click with replace prop uses router.replace", async () => {
|
|
1456
|
+
const el = container()
|
|
1457
|
+
const router = createRouter({ routes, url: "/" })
|
|
1458
|
+
let replaceCalled = false
|
|
1459
|
+
const origReplace = router.replace.bind(router)
|
|
1460
|
+
router.replace = async (path: string) => {
|
|
1461
|
+
replaceCalled = true
|
|
1462
|
+
return origReplace(path)
|
|
1463
|
+
}
|
|
1464
|
+
mount(
|
|
1465
|
+
h(RouterProvider, { router }, h(RouterLink, { to: "/about", replace: true }, "About")),
|
|
1466
|
+
el,
|
|
1467
|
+
)
|
|
1468
|
+
const anchor = el.querySelector("a")
|
|
1469
|
+
const event = new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
1470
|
+
anchor?.dispatchEvent(event)
|
|
1471
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
1472
|
+
expect(replaceCalled).toBe(true)
|
|
1473
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
test("mouseenter triggers prefetch (hover mode) via onMouseEnter prop", async () => {
|
|
1477
|
+
// Render RouterLink and manually trigger the onMouseEnter handler
|
|
1478
|
+
const el = container()
|
|
1479
|
+
let loaderCalled = false
|
|
1480
|
+
const prefetchRoutes: RouteRecord[] = [
|
|
1481
|
+
{ path: "/", component: Home },
|
|
1482
|
+
{
|
|
1483
|
+
path: "/data",
|
|
1484
|
+
component: About,
|
|
1485
|
+
loader: async () => {
|
|
1486
|
+
loaderCalled = true
|
|
1487
|
+
return "data"
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
]
|
|
1491
|
+
const router = createRouter({ routes: prefetchRoutes, url: "/" })
|
|
1492
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/data" }, "Data")), el)
|
|
1493
|
+
const anchor = el.querySelector("a") as HTMLAnchorElement
|
|
1494
|
+
expect(anchor).not.toBeNull()
|
|
1495
|
+
// applyProp converts onMouseEnter -> addEventListener("mouseEnter", ...) via:
|
|
1496
|
+
// key[2].toLowerCase() + key.slice(3) = "m" + "ouseEnter" = "mouseEnter"
|
|
1497
|
+
// So the event type is "mouseEnter" (camelCase), not "mouseenter"
|
|
1498
|
+
anchor.dispatchEvent(new Event("mouseEnter"))
|
|
1499
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
1500
|
+
expect(loaderCalled).toBe(true)
|
|
1501
|
+
})
|
|
1502
|
+
|
|
1503
|
+
test("prefetch=none does not trigger on mouseenter", () => {
|
|
1504
|
+
// Test that the rendered RouterLink with prefetch=none does not set up hover prefetch.
|
|
1505
|
+
// We test the component behavior by verifying the props flow.
|
|
1506
|
+
const el = container()
|
|
1507
|
+
let loaderCalled = false
|
|
1508
|
+
const prefetchRoutes: RouteRecord[] = [
|
|
1509
|
+
{ path: "/", component: Home },
|
|
1510
|
+
{
|
|
1511
|
+
path: "/data",
|
|
1512
|
+
component: About,
|
|
1513
|
+
loader: async () => {
|
|
1514
|
+
loaderCalled = true
|
|
1515
|
+
return "data"
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
]
|
|
1519
|
+
const router = createRouter({ routes: prefetchRoutes, url: "/" })
|
|
1520
|
+
mount(
|
|
1521
|
+
h(RouterProvider, { router }, h(RouterLink, { to: "/data", prefetch: "none" }, "Data")),
|
|
1522
|
+
el,
|
|
1523
|
+
)
|
|
1524
|
+
// No prefetch should have been called during mount
|
|
1525
|
+
expect(loaderCalled).toBe(false)
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
test("prefetch deduplicates by path via prefetchLoaderData", async () => {
|
|
1529
|
+
let loaderCallCount = 0
|
|
1530
|
+
const prefetchRoutes: RouteRecord[] = [
|
|
1531
|
+
{ path: "/", component: Home },
|
|
1532
|
+
{
|
|
1533
|
+
path: "/data",
|
|
1534
|
+
component: About,
|
|
1535
|
+
loader: async () => {
|
|
1536
|
+
loaderCallCount++
|
|
1537
|
+
return "data"
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
]
|
|
1541
|
+
const router = createRouter({ routes: prefetchRoutes, url: "/" }) as RouterInstance
|
|
1542
|
+
await prefetchLoaderData(router, "/data")
|
|
1543
|
+
expect(loaderCallCount).toBe(1)
|
|
1544
|
+
})
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
// ─── ScrollManager (DOM tests) ──────────────────────────────────────────────
|
|
1548
|
+
|
|
1549
|
+
describe("ScrollManager (DOM)", () => {
|
|
1550
|
+
test("save stores window.scrollY", () => {
|
|
1551
|
+
const sm = new ScrollManager()
|
|
1552
|
+
// happy-dom has window.scrollY = 0 by default
|
|
1553
|
+
sm.save("/page1")
|
|
1554
|
+
expect(sm.getSavedPosition("/page1")).toBe(0)
|
|
1555
|
+
})
|
|
1556
|
+
|
|
1557
|
+
test("restore with 'top' scrolls to top", () => {
|
|
1558
|
+
const sm = new ScrollManager("top")
|
|
1559
|
+
let scrolledTo: number | undefined
|
|
1560
|
+
const origScrollTo = window.scrollTo
|
|
1561
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
1562
|
+
const opts = args[0] as { top?: number }
|
|
1563
|
+
scrolledTo = opts?.top
|
|
1564
|
+
}) as typeof window.scrollTo
|
|
1565
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
1566
|
+
const from: ResolvedRoute = {
|
|
1567
|
+
path: "/b",
|
|
1568
|
+
params: {},
|
|
1569
|
+
query: {},
|
|
1570
|
+
hash: "",
|
|
1571
|
+
matched: [],
|
|
1572
|
+
meta: {},
|
|
1573
|
+
}
|
|
1574
|
+
sm.restore(to, from)
|
|
1575
|
+
expect(scrolledTo).toBe(0)
|
|
1576
|
+
window.scrollTo = origScrollTo
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
test("restore with 'restore' uses saved position", () => {
|
|
1580
|
+
const sm = new ScrollManager("restore")
|
|
1581
|
+
// Save a position for the target path
|
|
1582
|
+
sm.save("/target")
|
|
1583
|
+
let scrolledTo: number | undefined
|
|
1584
|
+
const origScrollTo = window.scrollTo
|
|
1585
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
1586
|
+
const opts = args[0] as { top?: number }
|
|
1587
|
+
scrolledTo = opts?.top
|
|
1588
|
+
}) as typeof window.scrollTo
|
|
1589
|
+
const to: ResolvedRoute = {
|
|
1590
|
+
path: "/target",
|
|
1591
|
+
params: {},
|
|
1592
|
+
query: {},
|
|
1593
|
+
hash: "",
|
|
1594
|
+
matched: [],
|
|
1595
|
+
meta: {},
|
|
1596
|
+
}
|
|
1597
|
+
const from: ResolvedRoute = {
|
|
1598
|
+
path: "/other",
|
|
1599
|
+
params: {},
|
|
1600
|
+
query: {},
|
|
1601
|
+
hash: "",
|
|
1602
|
+
matched: [],
|
|
1603
|
+
meta: {},
|
|
1604
|
+
}
|
|
1605
|
+
sm.restore(to, from)
|
|
1606
|
+
expect(scrolledTo).toBe(0) // window.scrollY is 0 in happy-dom
|
|
1607
|
+
window.scrollTo = origScrollTo
|
|
1608
|
+
})
|
|
1609
|
+
|
|
1610
|
+
test("restore with 'none' does not scroll", () => {
|
|
1611
|
+
const sm = new ScrollManager("none")
|
|
1612
|
+
let scrollCalled = false
|
|
1613
|
+
const origScrollTo = window.scrollTo
|
|
1614
|
+
window.scrollTo = (() => {
|
|
1615
|
+
scrollCalled = true
|
|
1616
|
+
}) as typeof window.scrollTo
|
|
1617
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
1618
|
+
const from: ResolvedRoute = {
|
|
1619
|
+
path: "/b",
|
|
1620
|
+
params: {},
|
|
1621
|
+
query: {},
|
|
1622
|
+
hash: "",
|
|
1623
|
+
matched: [],
|
|
1624
|
+
meta: {},
|
|
1625
|
+
}
|
|
1626
|
+
sm.restore(to, from)
|
|
1627
|
+
expect(scrollCalled).toBe(false)
|
|
1628
|
+
window.scrollTo = origScrollTo
|
|
1629
|
+
})
|
|
1630
|
+
|
|
1631
|
+
test("restore with numeric result scrolls to that position", () => {
|
|
1632
|
+
const customFn = () => 250
|
|
1633
|
+
const sm = new ScrollManager(customFn)
|
|
1634
|
+
let scrolledTo: number | undefined
|
|
1635
|
+
const origScrollTo = window.scrollTo
|
|
1636
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
1637
|
+
const opts = args[0] as { top?: number }
|
|
1638
|
+
scrolledTo = opts?.top
|
|
1639
|
+
}) as typeof window.scrollTo
|
|
1640
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
1641
|
+
const from: ResolvedRoute = {
|
|
1642
|
+
path: "/b",
|
|
1643
|
+
params: {},
|
|
1644
|
+
query: {},
|
|
1645
|
+
hash: "",
|
|
1646
|
+
matched: [],
|
|
1647
|
+
meta: {},
|
|
1648
|
+
}
|
|
1649
|
+
sm.restore(to, from)
|
|
1650
|
+
expect(scrolledTo).toBe(250)
|
|
1651
|
+
window.scrollTo = origScrollTo
|
|
1652
|
+
})
|
|
1653
|
+
|
|
1654
|
+
test("restore uses custom function with savedPosition", () => {
|
|
1655
|
+
let receivedSaved: number | null = null
|
|
1656
|
+
const customFn = (_to: ResolvedRoute, _from: ResolvedRoute, saved: number | null) => {
|
|
1657
|
+
receivedSaved = saved
|
|
1658
|
+
return "top" as const
|
|
1659
|
+
}
|
|
1660
|
+
const sm = new ScrollManager(customFn)
|
|
1661
|
+
sm.save("/target")
|
|
1662
|
+
const origScrollTo = window.scrollTo
|
|
1663
|
+
window.scrollTo = (() => {}) as typeof window.scrollTo
|
|
1664
|
+
const to: ResolvedRoute = {
|
|
1665
|
+
path: "/target",
|
|
1666
|
+
params: {},
|
|
1667
|
+
query: {},
|
|
1668
|
+
hash: "",
|
|
1669
|
+
matched: [],
|
|
1670
|
+
meta: {},
|
|
1671
|
+
}
|
|
1672
|
+
const from: ResolvedRoute = {
|
|
1673
|
+
path: "/other",
|
|
1674
|
+
params: {},
|
|
1675
|
+
query: {},
|
|
1676
|
+
hash: "",
|
|
1677
|
+
matched: [],
|
|
1678
|
+
meta: {},
|
|
1679
|
+
}
|
|
1680
|
+
sm.restore(to, from)
|
|
1681
|
+
expect(receivedSaved as unknown as number).toBe(0) // saved position exists (0 from happy-dom)
|
|
1682
|
+
window.scrollTo = origScrollTo
|
|
1683
|
+
})
|
|
1684
|
+
|
|
1685
|
+
test("per-route meta.scrollBehavior overrides global", () => {
|
|
1686
|
+
const sm = new ScrollManager("top")
|
|
1687
|
+
let scrollCalled = false
|
|
1688
|
+
const origScrollTo = window.scrollTo
|
|
1689
|
+
window.scrollTo = (() => {
|
|
1690
|
+
scrollCalled = true
|
|
1691
|
+
}) as typeof window.scrollTo
|
|
1692
|
+
const to: ResolvedRoute = {
|
|
1693
|
+
path: "/a",
|
|
1694
|
+
params: {},
|
|
1695
|
+
query: {},
|
|
1696
|
+
hash: "",
|
|
1697
|
+
matched: [],
|
|
1698
|
+
meta: { scrollBehavior: "none" },
|
|
1699
|
+
}
|
|
1700
|
+
const from: ResolvedRoute = {
|
|
1701
|
+
path: "/b",
|
|
1702
|
+
params: {},
|
|
1703
|
+
query: {},
|
|
1704
|
+
hash: "",
|
|
1705
|
+
matched: [],
|
|
1706
|
+
meta: {},
|
|
1707
|
+
}
|
|
1708
|
+
sm.restore(to, from)
|
|
1709
|
+
expect(scrollCalled).toBe(false)
|
|
1710
|
+
window.scrollTo = origScrollTo
|
|
1711
|
+
})
|
|
1712
|
+
})
|
|
1713
|
+
|
|
1714
|
+
// ─── Router navigation (DOM mode — with window) ─────────────────────────────
|
|
1715
|
+
|
|
1716
|
+
describe("router navigation (DOM hash mode)", () => {
|
|
1717
|
+
test("push updates window.location.hash", async () => {
|
|
1718
|
+
const router = createRouter({ routes, mode: "hash" })
|
|
1719
|
+
await router.push("/about")
|
|
1720
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
1721
|
+
// history.pushState was used, so hash should be set
|
|
1722
|
+
expect(window.location.hash).toBe("#/about")
|
|
1723
|
+
})
|
|
1724
|
+
|
|
1725
|
+
test("replace updates hash via replaceState", async () => {
|
|
1726
|
+
const router = createRouter({ routes, mode: "hash" })
|
|
1727
|
+
await router.replace("/about")
|
|
1728
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
1729
|
+
expect(window.location.hash).toBe("#/about")
|
|
1730
|
+
})
|
|
1731
|
+
})
|
|
1732
|
+
|
|
1733
|
+
describe("router navigation (DOM history mode)", () => {
|
|
1734
|
+
test("push calls history.pushState in history mode", async () => {
|
|
1735
|
+
let pushStateCalled = false
|
|
1736
|
+
let pushedUrl = ""
|
|
1737
|
+
const origPushState = window.history.pushState
|
|
1738
|
+
window.history.pushState = (data: unknown, unused: string, url?: string | URL | null) => {
|
|
1739
|
+
pushStateCalled = true
|
|
1740
|
+
pushedUrl = String(url ?? "")
|
|
1741
|
+
origPushState.call(window.history, data, unused, url)
|
|
1742
|
+
}
|
|
1743
|
+
const router = createRouter({ routes, mode: "history" })
|
|
1744
|
+
await router.push("/about")
|
|
1745
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
1746
|
+
expect(pushStateCalled).toBe(true)
|
|
1747
|
+
expect(pushedUrl).toBe("/about")
|
|
1748
|
+
window.history.pushState = origPushState
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
test("replace calls history.replaceState in history mode", async () => {
|
|
1752
|
+
let replaceStateCalled = false
|
|
1753
|
+
let replacedUrl = ""
|
|
1754
|
+
const origReplaceState = window.history.replaceState
|
|
1755
|
+
window.history.replaceState = (data: unknown, unused: string, url?: string | URL | null) => {
|
|
1756
|
+
replaceStateCalled = true
|
|
1757
|
+
replacedUrl = String(url ?? "")
|
|
1758
|
+
origReplaceState.call(window.history, data, unused, url)
|
|
1759
|
+
}
|
|
1760
|
+
const router = createRouter({ routes, mode: "history" })
|
|
1761
|
+
await router.replace("/user/42")
|
|
1762
|
+
expect(router.currentRoute().path).toBe("/user/42")
|
|
1763
|
+
expect(replaceStateCalled).toBe(true)
|
|
1764
|
+
expect(replacedUrl).toBe("/user/42")
|
|
1765
|
+
window.history.replaceState = origReplaceState
|
|
1766
|
+
})
|
|
1767
|
+
})
|
|
1768
|
+
|
|
1769
|
+
// ─── document.title from route meta ──────────────────────────────────────────
|
|
1770
|
+
|
|
1771
|
+
describe("document.title from meta", () => {
|
|
1772
|
+
test("sets document.title on navigation when meta.title is set", async () => {
|
|
1773
|
+
const titleRoutes: RouteRecord[] = [
|
|
1774
|
+
{ path: "/", component: Home },
|
|
1775
|
+
{ path: "/titled", component: About, meta: { title: "My Page" } },
|
|
1776
|
+
]
|
|
1777
|
+
const router = createRouter({ routes: titleRoutes })
|
|
1778
|
+
const origTitle = document.title
|
|
1779
|
+
await router.push("/titled")
|
|
1780
|
+
expect(document.title).toBe("My Page")
|
|
1781
|
+
document.title = origTitle
|
|
1782
|
+
})
|
|
1783
|
+
})
|
|
1784
|
+
|
|
1785
|
+
// ─── matchPrefix with params (match.ts lines 80, 82) ────────────────────────
|
|
1786
|
+
|
|
1787
|
+
describe("nested route matching with params", () => {
|
|
1788
|
+
test("nested route extracts params from parent prefix", () => {
|
|
1789
|
+
const paramNested: RouteRecord[] = [
|
|
1790
|
+
{
|
|
1791
|
+
path: "/user/:id",
|
|
1792
|
+
component: Home,
|
|
1793
|
+
children: [{ path: "posts", component: About }],
|
|
1794
|
+
},
|
|
1795
|
+
]
|
|
1796
|
+
const r = resolveRoute("/user/42/posts", paramNested)
|
|
1797
|
+
expect(r.matched).toHaveLength(2)
|
|
1798
|
+
expect(r.params.id).toBe("42")
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
test("nested route merges parent and child params", () => {
|
|
1802
|
+
const paramNested: RouteRecord[] = [
|
|
1803
|
+
{
|
|
1804
|
+
path: "/org/:orgId",
|
|
1805
|
+
component: Home,
|
|
1806
|
+
children: [{ path: "team/:teamId", component: About }],
|
|
1807
|
+
},
|
|
1808
|
+
]
|
|
1809
|
+
const r = resolveRoute("/org/acme/team/dev", paramNested)
|
|
1810
|
+
expect(r.matched).toHaveLength(2)
|
|
1811
|
+
expect(r.params.orgId).toBe("acme")
|
|
1812
|
+
expect(r.params.teamId).toBe("dev")
|
|
1813
|
+
})
|
|
1814
|
+
|
|
1815
|
+
test("parent with children but no matching child and no exact match returns empty", () => {
|
|
1816
|
+
const routesDef: RouteRecord[] = [
|
|
1817
|
+
{
|
|
1818
|
+
path: "/admin",
|
|
1819
|
+
component: AdminLayout,
|
|
1820
|
+
children: [{ path: "users", component: AdminUsers }],
|
|
1821
|
+
},
|
|
1822
|
+
]
|
|
1823
|
+
// /admin/settings does not match any child, and /admin/settings != /admin
|
|
1824
|
+
const r = resolveRoute("/admin/settings", routesDef)
|
|
1825
|
+
expect(r.matched).toHaveLength(0)
|
|
1826
|
+
})
|
|
1827
|
+
|
|
1828
|
+
test("parent route with children matches exact parent path", () => {
|
|
1829
|
+
const routesDef: RouteRecord[] = [
|
|
1830
|
+
{
|
|
1831
|
+
path: "/admin",
|
|
1832
|
+
component: AdminLayout,
|
|
1833
|
+
children: [{ path: "users", component: AdminUsers }],
|
|
1834
|
+
},
|
|
1835
|
+
]
|
|
1836
|
+
// /admin exact matches the parent
|
|
1837
|
+
const r = resolveRoute("/admin", routesDef)
|
|
1838
|
+
expect(r.matched).toHaveLength(1)
|
|
1839
|
+
expect(r.matched[0]?.component).toBe(AdminLayout)
|
|
1840
|
+
})
|
|
1841
|
+
})
|
|
1842
|
+
|
|
1843
|
+
// ─── Lazy route rendering in RouterView ──────────────────────────────────────
|
|
1844
|
+
|
|
1845
|
+
describe("RouterView with lazy routes", () => {
|
|
1846
|
+
test("lazy route shows loading component initially", async () => {
|
|
1847
|
+
const el = container()
|
|
1848
|
+
const LoadingComp = () => h("span", null, "loading...")
|
|
1849
|
+
const ActualComp = () => h("span", null, "loaded!")
|
|
1850
|
+
const lazyComp = lazy(
|
|
1851
|
+
() =>
|
|
1852
|
+
new Promise<{ default: typeof ActualComp }>((res) =>
|
|
1853
|
+
setTimeout(() => res({ default: ActualComp }), 50),
|
|
1854
|
+
),
|
|
1855
|
+
{ loading: LoadingComp },
|
|
1856
|
+
)
|
|
1857
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
1858
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1859
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1860
|
+
// Initially shows loading
|
|
1861
|
+
expect(el.textContent).toContain("loading...")
|
|
1862
|
+
})
|
|
1863
|
+
|
|
1864
|
+
test("lazy route resolves and populates component cache", async () => {
|
|
1865
|
+
const ActualComp = () => h("span", null, "loaded!")
|
|
1866
|
+
const lazyComp = lazy(() => Promise.resolve(ActualComp))
|
|
1867
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
1868
|
+
const router = createRouter({ routes: viewRoutes, url: "/" }) as RouterInstance
|
|
1869
|
+
// Initially the cache is empty
|
|
1870
|
+
expect(router._componentCache.size).toBe(0)
|
|
1871
|
+
// Mount triggers the lazy load
|
|
1872
|
+
const el = container()
|
|
1873
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1874
|
+
// Wait for the Promise to resolve + signal update
|
|
1875
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
1876
|
+
// After lazy load, the resolved component is cached
|
|
1877
|
+
expect(router._componentCache.size).toBe(1)
|
|
1878
|
+
const cached = Array.from(router._componentCache.values())[0]
|
|
1879
|
+
expect(cached).toBe(ActualComp)
|
|
1880
|
+
})
|
|
1881
|
+
|
|
1882
|
+
test("lazy route with module default export", async () => {
|
|
1883
|
+
const el = container()
|
|
1884
|
+
const ActualComp = () => h("span", null, "default-export")
|
|
1885
|
+
const lazyComp = lazy(() => Promise.resolve({ default: ActualComp }))
|
|
1886
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
1887
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1888
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1889
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
1890
|
+
expect(el.textContent).toContain("default-export")
|
|
1891
|
+
})
|
|
1892
|
+
})
|
|
1893
|
+
|
|
1894
|
+
// ─── isStaleChunk helper (components.tsx lines 240-242) ──────────────────────
|
|
1895
|
+
|
|
1896
|
+
describe("RouterView lazy error handling", () => {
|
|
1897
|
+
test("lazy route shows error component after retries fail", async () => {
|
|
1898
|
+
const el = container()
|
|
1899
|
+
const ErrorComp = () => h("span", null, "error-ui")
|
|
1900
|
+
let attempts = 0
|
|
1901
|
+
const lazyComp = lazy(
|
|
1902
|
+
() => {
|
|
1903
|
+
attempts++
|
|
1904
|
+
return Promise.reject(new Error("chunk failed"))
|
|
1905
|
+
},
|
|
1906
|
+
{ error: ErrorComp },
|
|
1907
|
+
)
|
|
1908
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
1909
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1910
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1911
|
+
// Wait for initial attempt + 3 retries with exponential backoff
|
|
1912
|
+
// attempt 0 immediate, retry 1 at 500ms, retry 2 at 1000ms, retry 3 at 2000ms
|
|
1913
|
+
await new Promise<void>((r) => setTimeout(r, 4500))
|
|
1914
|
+
expect(el.textContent).toContain("error-ui")
|
|
1915
|
+
expect(attempts).toBeGreaterThanOrEqual(3)
|
|
1916
|
+
}, 10000)
|
|
1917
|
+
|
|
1918
|
+
test("lazy route shows null when error component not provided and retries fail", async () => {
|
|
1919
|
+
const el = container()
|
|
1920
|
+
const lazyComp = lazy(() => Promise.reject(new Error("chunk failed")))
|
|
1921
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
1922
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
1923
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
1924
|
+
await new Promise<void>((r) => setTimeout(r, 4500))
|
|
1925
|
+
// The wrapper div exists but component content should be empty
|
|
1926
|
+
const wrapper = el.querySelector("[data-pyreon-router-view]")
|
|
1927
|
+
// Text inside wrapper should be empty (only comment nodes)
|
|
1928
|
+
const spans = wrapper?.querySelectorAll("span")
|
|
1929
|
+
expect(spans?.length ?? 0).toBe(0)
|
|
1930
|
+
}, 10000)
|
|
1931
|
+
})
|
|
1932
|
+
|
|
1933
|
+
// ─── Concurrent navigation cancels stale loaders ─────────────────────────────
|
|
1934
|
+
|
|
1935
|
+
describe("concurrent navigation cancels in-flight loaders", () => {
|
|
1936
|
+
test("stale loader is aborted when new navigation starts", async () => {
|
|
1937
|
+
let slowAborted = false
|
|
1938
|
+
const concRoutes: RouteRecord[] = [
|
|
1939
|
+
{ path: "/", component: Home },
|
|
1940
|
+
{
|
|
1941
|
+
path: "/slow",
|
|
1942
|
+
component: About,
|
|
1943
|
+
loader: async ({ signal }) => {
|
|
1944
|
+
signal.addEventListener("abort", () => {
|
|
1945
|
+
slowAborted = true
|
|
1946
|
+
})
|
|
1947
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
1948
|
+
return "slow"
|
|
1949
|
+
},
|
|
1950
|
+
},
|
|
1951
|
+
{ path: "/fast", component: User, loader: async () => "fast" },
|
|
1952
|
+
]
|
|
1953
|
+
const router = createRouter(concRoutes)
|
|
1954
|
+
|
|
1955
|
+
const slow = router.push("/slow")
|
|
1956
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
1957
|
+
await router.push("/fast")
|
|
1958
|
+
await slow
|
|
1959
|
+
|
|
1960
|
+
expect(slowAborted).toBe(true)
|
|
1961
|
+
expect(router.currentRoute().path).toBe("/fast")
|
|
1962
|
+
})
|
|
1963
|
+
})
|
|
1964
|
+
|
|
1965
|
+
// ─── back() in DOM environment ───────────────────────────────────────────────
|
|
1966
|
+
|
|
1967
|
+
describe("back() in DOM", () => {
|
|
1968
|
+
test("back() calls window.history.back", () => {
|
|
1969
|
+
const router = createRouter({ routes })
|
|
1970
|
+
let backCalled = false
|
|
1971
|
+
const origBack = window.history.back
|
|
1972
|
+
window.history.back = () => {
|
|
1973
|
+
backCalled = true
|
|
1974
|
+
}
|
|
1975
|
+
router.back()
|
|
1976
|
+
expect(backCalled).toBe(true)
|
|
1977
|
+
window.history.back = origBack
|
|
1978
|
+
})
|
|
1979
|
+
})
|
|
1980
|
+
|
|
1981
|
+
// ─── Scroll behavior integration via navigation ─────────────────────────────
|
|
1982
|
+
|
|
1983
|
+
describe("scroll behavior on navigation", () => {
|
|
1984
|
+
test("navigation triggers scroll restore via microtask", async () => {
|
|
1985
|
+
let scrolledTo: number | undefined
|
|
1986
|
+
const origScrollTo = window.scrollTo
|
|
1987
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
1988
|
+
const opts = args[0] as { top?: number }
|
|
1989
|
+
scrolledTo = opts?.top
|
|
1990
|
+
}) as typeof window.scrollTo
|
|
1991
|
+
|
|
1992
|
+
const router = createRouter({ routes, scrollBehavior: "top" })
|
|
1993
|
+
await router.push("/about")
|
|
1994
|
+
// Wait for microtask
|
|
1995
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
1996
|
+
expect(scrolledTo).toBe(0)
|
|
1997
|
+
|
|
1998
|
+
window.scrollTo = origScrollTo
|
|
1999
|
+
})
|
|
2000
|
+
})
|
|
2001
|
+
|
|
2002
|
+
// ─── History mode popstate (router.ts line 90 — getCurrentLocation) ─────────
|
|
2003
|
+
|
|
2004
|
+
describe("history mode popstate", () => {
|
|
2005
|
+
test("popstate event updates currentRoute in history mode", async () => {
|
|
2006
|
+
const router = createRouter({ routes, mode: "history" })
|
|
2007
|
+
// Push an initial route so we have something in history
|
|
2008
|
+
await router.push("/about")
|
|
2009
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
2010
|
+
// Simulate back navigation via popstate — we need to manually set the URL
|
|
2011
|
+
// first because happy-dom doesn't actually track history.
|
|
2012
|
+
// pushState to set pathname, then dispatch popstate
|
|
2013
|
+
window.history.pushState(null, "", "/user/99")
|
|
2014
|
+
window.dispatchEvent(new PopStateEvent("popstate"))
|
|
2015
|
+
// getCurrentLocation reads window.location.pathname + search
|
|
2016
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
2017
|
+
// The path should now reflect whatever pathname + search the browser reports
|
|
2018
|
+
expect(router.currentRoute().path).not.toBe("/about")
|
|
2019
|
+
})
|
|
2020
|
+
})
|
|
2021
|
+
|
|
2022
|
+
// ─── Prefetch error path (components.tsx lines 198-199) ──────────────────────
|
|
2023
|
+
|
|
2024
|
+
describe("prefetch error handling", () => {
|
|
2025
|
+
test("prefetch error is silently caught in RouterLink's prefetchRoute", async () => {
|
|
2026
|
+
// The prefetchRoute function in components.tsx catches errors from
|
|
2027
|
+
// prefetchLoaderData and removes the path from the prefetched set.
|
|
2028
|
+
// We test this by hovering over a link whose loader will fail.
|
|
2029
|
+
const el = container()
|
|
2030
|
+
let loaderCallCount = 0
|
|
2031
|
+
const failRoutes: RouteRecord[] = [
|
|
2032
|
+
{ path: "/", component: Home },
|
|
2033
|
+
{
|
|
2034
|
+
path: "/fail",
|
|
2035
|
+
component: About,
|
|
2036
|
+
loader: async () => {
|
|
2037
|
+
loaderCallCount++
|
|
2038
|
+
throw new Error("prefetch error")
|
|
2039
|
+
},
|
|
2040
|
+
},
|
|
2041
|
+
]
|
|
2042
|
+
const router = createRouter({ routes: failRoutes, url: "/" })
|
|
2043
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/fail" }, "Fail")), el)
|
|
2044
|
+
const anchor = el.querySelector("a") as HTMLAnchorElement
|
|
2045
|
+
anchor.dispatchEvent(new Event("mouseEnter"))
|
|
2046
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2047
|
+
expect(loaderCallCount).toBe(1)
|
|
2048
|
+
// After the error, the path is removed from prefetched set, so hovering again
|
|
2049
|
+
// should trigger another attempt
|
|
2050
|
+
anchor.dispatchEvent(new Event("mouseEnter"))
|
|
2051
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2052
|
+
expect(loaderCallCount).toBe(2)
|
|
2053
|
+
})
|
|
2054
|
+
})
|
|
2055
|
+
|
|
2056
|
+
// ─── Stale chunk detection (components.tsx lines 111-112) ────────────────────
|
|
2057
|
+
|
|
2058
|
+
describe("stale chunk detection", () => {
|
|
2059
|
+
test("TypeError 'Failed to fetch' triggers window.location.reload", async () => {
|
|
2060
|
+
const el = container()
|
|
2061
|
+
let reloadCalled = false
|
|
2062
|
+
// Mock window.location.reload
|
|
2063
|
+
const origReload = window.location.reload
|
|
2064
|
+
Object.defineProperty(window.location, "reload", {
|
|
2065
|
+
value: () => {
|
|
2066
|
+
reloadCalled = true
|
|
2067
|
+
},
|
|
2068
|
+
writable: true,
|
|
2069
|
+
configurable: true,
|
|
2070
|
+
})
|
|
2071
|
+
|
|
2072
|
+
const lazyComp = lazy(() =>
|
|
2073
|
+
Promise.reject(new TypeError("Failed to fetch dynamically imported module")),
|
|
2074
|
+
)
|
|
2075
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
2076
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
2077
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
2078
|
+
// Wait for all retries to exhaust (500 + 1000 + 2000 = 3500ms)
|
|
2079
|
+
await new Promise<void>((r) => setTimeout(r, 4500))
|
|
2080
|
+
expect(reloadCalled).toBe(true)
|
|
2081
|
+
|
|
2082
|
+
// Restore
|
|
2083
|
+
Object.defineProperty(window.location, "reload", {
|
|
2084
|
+
value: origReload,
|
|
2085
|
+
writable: true,
|
|
2086
|
+
configurable: true,
|
|
2087
|
+
})
|
|
2088
|
+
}, 10000)
|
|
2089
|
+
|
|
2090
|
+
test("SyntaxError triggers window.location.reload for stale chunk", async () => {
|
|
2091
|
+
const el = container()
|
|
2092
|
+
let reloadCalled = false
|
|
2093
|
+
const origReload = window.location.reload
|
|
2094
|
+
Object.defineProperty(window.location, "reload", {
|
|
2095
|
+
value: () => {
|
|
2096
|
+
reloadCalled = true
|
|
2097
|
+
},
|
|
2098
|
+
writable: true,
|
|
2099
|
+
configurable: true,
|
|
2100
|
+
})
|
|
2101
|
+
|
|
2102
|
+
const lazyComp = lazy(() => Promise.reject(new SyntaxError("Unexpected token '<'")))
|
|
2103
|
+
const viewRoutes: RouteRecord[] = [{ path: "/", component: lazyComp as unknown as typeof Home }]
|
|
2104
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
2105
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
2106
|
+
await new Promise<void>((r) => setTimeout(r, 4500))
|
|
2107
|
+
expect(reloadCalled).toBe(true)
|
|
2108
|
+
|
|
2109
|
+
Object.defineProperty(window.location, "reload", {
|
|
2110
|
+
value: origReload,
|
|
2111
|
+
writable: true,
|
|
2112
|
+
configurable: true,
|
|
2113
|
+
})
|
|
2114
|
+
}, 10000)
|
|
2115
|
+
})
|
|
2116
|
+
|
|
2117
|
+
// ─── parseQueryMulti ─────────────────────────────────────────────────────────
|
|
2118
|
+
|
|
2119
|
+
describe("parseQueryMulti", () => {
|
|
2120
|
+
let parseQueryMulti: (qs: string) => Record<string, string | string[]>
|
|
2121
|
+
|
|
2122
|
+
beforeAll(async () => {
|
|
2123
|
+
const mod = await import("../match")
|
|
2124
|
+
parseQueryMulti = mod.parseQueryMulti
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
test("returns empty for empty string", () => {
|
|
2128
|
+
expect(parseQueryMulti("")).toEqual({})
|
|
2129
|
+
})
|
|
2130
|
+
|
|
2131
|
+
test("parses single key-value pair", () => {
|
|
2132
|
+
expect(parseQueryMulti("a=1")).toEqual({ a: "1" })
|
|
2133
|
+
})
|
|
2134
|
+
|
|
2135
|
+
test("returns array for duplicate keys", () => {
|
|
2136
|
+
expect(parseQueryMulti("color=red&color=blue")).toEqual({ color: ["red", "blue"] })
|
|
2137
|
+
})
|
|
2138
|
+
|
|
2139
|
+
test("handles mix of single and duplicate keys", () => {
|
|
2140
|
+
const result = parseQueryMulti("color=red&color=blue&size=lg")
|
|
2141
|
+
expect(result.color).toEqual(["red", "blue"])
|
|
2142
|
+
expect(result.size).toBe("lg")
|
|
2143
|
+
})
|
|
2144
|
+
|
|
2145
|
+
test("handles keys without values", () => {
|
|
2146
|
+
expect(parseQueryMulti("flag")).toEqual({ flag: "" })
|
|
2147
|
+
})
|
|
2148
|
+
|
|
2149
|
+
test("skips empty key parts", () => {
|
|
2150
|
+
const result = parseQueryMulti("=value")
|
|
2151
|
+
expect(Object.keys(result)).toHaveLength(0)
|
|
2152
|
+
})
|
|
2153
|
+
|
|
2154
|
+
test("handles triple duplicate", () => {
|
|
2155
|
+
const result = parseQueryMulti("a=1&a=2&a=3")
|
|
2156
|
+
expect(result.a).toEqual(["1", "2", "3"])
|
|
2157
|
+
})
|
|
2158
|
+
})
|
|
2159
|
+
|
|
2160
|
+
// ─── matchPath splat params ─────────────────────────────────────────────────
|
|
2161
|
+
|
|
2162
|
+
describe("matchPath splat params", () => {
|
|
2163
|
+
test("captures rest of path with splat param", () => {
|
|
2164
|
+
const result = matchPath("/files/:path*", "/files/a/b/c")
|
|
2165
|
+
expect(result).not.toBeNull()
|
|
2166
|
+
expect(result?.path).toBe("a/b/c")
|
|
2167
|
+
})
|
|
2168
|
+
|
|
2169
|
+
test("captures single segment with splat param", () => {
|
|
2170
|
+
const result = matchPath("/files/:path*", "/files/readme.md")
|
|
2171
|
+
expect(result).not.toBeNull()
|
|
2172
|
+
expect(result?.path).toBe("readme.md")
|
|
2173
|
+
})
|
|
2174
|
+
})
|
|
2175
|
+
|
|
2176
|
+
// ─── buildPath splat params ─────────────────────────────────────────────────
|
|
2177
|
+
|
|
2178
|
+
describe("buildPath splat params", () => {
|
|
2179
|
+
test("builds path with splat param", () => {
|
|
2180
|
+
// buildPath regex captures "path*" as key (including the asterisk)
|
|
2181
|
+
const result = buildPath("/files/:path*", { "path*": "a/b/c" })
|
|
2182
|
+
expect(result).toBe("/files/a/b/c")
|
|
2183
|
+
})
|
|
2184
|
+
|
|
2185
|
+
test("encodes individual segments in splat", () => {
|
|
2186
|
+
// buildPath regex captures "path*" as key (including the asterisk)
|
|
2187
|
+
const result = buildPath("/files/:path*", { "path*": "hello world/file name" })
|
|
2188
|
+
expect(result).toBe("/files/hello%20world/file%20name")
|
|
2189
|
+
})
|
|
2190
|
+
|
|
2191
|
+
test("handles missing param gracefully", () => {
|
|
2192
|
+
const result = buildPath("/user/:id", {})
|
|
2193
|
+
expect(result).toBe("/user/")
|
|
2194
|
+
})
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
// ─── Nested routes with splat prefix ────────────────────────────────────────
|
|
2198
|
+
|
|
2199
|
+
describe("matchPrefix with splat", () => {
|
|
2200
|
+
test("splat param in parent captures everything and sets rest to /", () => {
|
|
2201
|
+
const splatRoutes: RouteRecord[] = [
|
|
2202
|
+
{
|
|
2203
|
+
path: "/:path*",
|
|
2204
|
+
component: Home,
|
|
2205
|
+
children: [{ path: "/", component: About }],
|
|
2206
|
+
},
|
|
2207
|
+
]
|
|
2208
|
+
const r = resolveRoute("/any/deep/path", splatRoutes)
|
|
2209
|
+
expect(r.matched.length).toBeGreaterThan(0)
|
|
2210
|
+
})
|
|
2211
|
+
|
|
2212
|
+
test("wildcard in prefix matches and passes rest", () => {
|
|
2213
|
+
const wildcardRoutes: RouteRecord[] = [
|
|
2214
|
+
{
|
|
2215
|
+
path: "*",
|
|
2216
|
+
component: Home,
|
|
2217
|
+
children: [{ path: "/", component: About }],
|
|
2218
|
+
},
|
|
2219
|
+
]
|
|
2220
|
+
const r = resolveRoute("/anything", wildcardRoutes)
|
|
2221
|
+
expect(r.matched.length).toBeGreaterThan(0)
|
|
2222
|
+
})
|
|
2223
|
+
})
|
|
2224
|
+
|
|
2225
|
+
// ─── onError callback ────────────────────────────────────────────────────────
|
|
2226
|
+
|
|
2227
|
+
describe("onError callback", () => {
|
|
2228
|
+
test("onError returning false cancels navigation on loader failure", async () => {
|
|
2229
|
+
const loaderRoutes: RouteRecord[] = [
|
|
2230
|
+
{ path: "/", component: Home },
|
|
2231
|
+
{
|
|
2232
|
+
path: "/fail",
|
|
2233
|
+
component: About,
|
|
2234
|
+
loader: async () => {
|
|
2235
|
+
throw new Error("loader error")
|
|
2236
|
+
},
|
|
2237
|
+
},
|
|
2238
|
+
]
|
|
2239
|
+
const router = createRouter({
|
|
2240
|
+
routes: loaderRoutes,
|
|
2241
|
+
url: "/",
|
|
2242
|
+
onError: () => false,
|
|
2243
|
+
})
|
|
2244
|
+
await router.push("/fail")
|
|
2245
|
+
// Navigation should be cancelled
|
|
2246
|
+
expect(router.currentRoute().path).toBe("/")
|
|
2247
|
+
})
|
|
2248
|
+
|
|
2249
|
+
test("onError returning undefined allows navigation to continue", async () => {
|
|
2250
|
+
const loaderRoutes: RouteRecord[] = [
|
|
2251
|
+
{ path: "/", component: Home },
|
|
2252
|
+
{
|
|
2253
|
+
path: "/fail",
|
|
2254
|
+
component: About,
|
|
2255
|
+
loader: async () => {
|
|
2256
|
+
throw new Error("loader error")
|
|
2257
|
+
},
|
|
2258
|
+
},
|
|
2259
|
+
]
|
|
2260
|
+
const router = createRouter({
|
|
2261
|
+
routes: loaderRoutes,
|
|
2262
|
+
url: "/",
|
|
2263
|
+
onError: () => undefined,
|
|
2264
|
+
})
|
|
2265
|
+
await router.push("/fail")
|
|
2266
|
+
expect(router.currentRoute().path).toBe("/fail")
|
|
2267
|
+
})
|
|
2268
|
+
})
|
|
2269
|
+
|
|
2270
|
+
// ─── RouterView with errorComponent ─────────────────────────────────────────
|
|
2271
|
+
|
|
2272
|
+
describe("RouterView with errorComponent on loader failure", () => {
|
|
2273
|
+
test("renders errorComponent when loader data is undefined", async () => {
|
|
2274
|
+
const el = container()
|
|
2275
|
+
const ErrorComp = () => h("span", null, "loader-error")
|
|
2276
|
+
const DataComp = () => h("span", null, "data")
|
|
2277
|
+
const viewRoutes: RouteRecord[] = [
|
|
2278
|
+
{ path: "/", component: Home },
|
|
2279
|
+
{
|
|
2280
|
+
path: "/err",
|
|
2281
|
+
component: DataComp,
|
|
2282
|
+
loader: async () => {
|
|
2283
|
+
throw new Error("fail")
|
|
2284
|
+
},
|
|
2285
|
+
errorComponent: ErrorComp,
|
|
2286
|
+
},
|
|
2287
|
+
]
|
|
2288
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
2289
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
2290
|
+
await router.push("/err")
|
|
2291
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
2292
|
+
expect(el.textContent).toContain("loader-error")
|
|
2293
|
+
})
|
|
2294
|
+
})
|
|
2295
|
+
|
|
2296
|
+
// ─── Component cache eviction ───────────────────────────────────────────────
|
|
2297
|
+
|
|
2298
|
+
describe("component cache eviction", () => {
|
|
2299
|
+
test("evicts oldest entry when cache exceeds maxCacheSize", async () => {
|
|
2300
|
+
const Comp1 = () => h("span", null, "c1")
|
|
2301
|
+
const Comp2 = () => h("span", null, "c2")
|
|
2302
|
+
const Comp3 = () => h("span", null, "c3")
|
|
2303
|
+
const cacheRoutes: RouteRecord[] = [
|
|
2304
|
+
{ path: "/a", component: Comp1 },
|
|
2305
|
+
{ path: "/b", component: Comp2 },
|
|
2306
|
+
{ path: "/c", component: Comp3 },
|
|
2307
|
+
]
|
|
2308
|
+
const router = createRouter({
|
|
2309
|
+
routes: cacheRoutes,
|
|
2310
|
+
url: "/a",
|
|
2311
|
+
maxCacheSize: 2,
|
|
2312
|
+
}) as RouterInstance
|
|
2313
|
+
|
|
2314
|
+
// Manually simulate cache population
|
|
2315
|
+
const el = container()
|
|
2316
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
2317
|
+
// Navigate to populate cache
|
|
2318
|
+
await router.push("/a")
|
|
2319
|
+
await router.push("/b")
|
|
2320
|
+
await router.push("/c")
|
|
2321
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
2322
|
+
// Cache size should never exceed maxCacheSize + 1 (the newest)
|
|
2323
|
+
expect(router._componentCache.size).toBeLessThanOrEqual(3)
|
|
2324
|
+
})
|
|
2325
|
+
})
|
|
2326
|
+
|
|
2327
|
+
// ─── RouterLink no-router edge cases ────────────────────────────────────────
|
|
2328
|
+
|
|
2329
|
+
describe("RouterLink edge cases", () => {
|
|
2330
|
+
test("RouterLink click without router does not throw", () => {
|
|
2331
|
+
const el = container()
|
|
2332
|
+
// Mount RouterLink without RouterProvider
|
|
2333
|
+
setActiveRouter(null)
|
|
2334
|
+
mount(h(RouterLink, { to: "/test" }), el)
|
|
2335
|
+
const anchor = el.querySelector("a")
|
|
2336
|
+
const event = new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
2337
|
+
// Should not throw
|
|
2338
|
+
expect(() => anchor?.dispatchEvent(event)).not.toThrow()
|
|
2339
|
+
})
|
|
2340
|
+
|
|
2341
|
+
test("RouterLink activeClass returns empty string without router", () => {
|
|
2342
|
+
const el = container()
|
|
2343
|
+
setActiveRouter(null)
|
|
2344
|
+
// Pop any leaked context entries from prior tests that mount RouterProvider
|
|
2345
|
+
// without unmounting (pushContext is not cleaned up without onUnmount).
|
|
2346
|
+
// Pop enough times to clear any stale entries (safe on empty stack).
|
|
2347
|
+
for (let i = 0; i < 50; i++) popContext()
|
|
2348
|
+
mount(h(RouterLink, { to: "/test" }), el)
|
|
2349
|
+
const anchor = el.querySelector("a")
|
|
2350
|
+
// class should be empty or not set
|
|
2351
|
+
const cls = anchor?.getAttribute("class") ?? ""
|
|
2352
|
+
expect(cls).toBe("")
|
|
2353
|
+
})
|
|
2354
|
+
})
|
|
2355
|
+
|
|
2356
|
+
// ─── RouterLink viewport prefetch (components.tsx lines 197-210) ─────────────
|
|
2357
|
+
|
|
2358
|
+
describe("RouterLink viewport prefetch", () => {
|
|
2359
|
+
test("viewport prefetch uses IntersectionObserver", async () => {
|
|
2360
|
+
const el = container()
|
|
2361
|
+
let loaderCalled = false
|
|
2362
|
+
const prefetchRoutes: RouteRecord[] = [
|
|
2363
|
+
{ path: "/", component: Home },
|
|
2364
|
+
{
|
|
2365
|
+
path: "/visible",
|
|
2366
|
+
component: About,
|
|
2367
|
+
loader: async () => {
|
|
2368
|
+
loaderCalled = true
|
|
2369
|
+
return "data"
|
|
2370
|
+
},
|
|
2371
|
+
},
|
|
2372
|
+
]
|
|
2373
|
+
const router = createRouter({ routes: prefetchRoutes, url: "/" })
|
|
2374
|
+
|
|
2375
|
+
// Mock IntersectionObserver to immediately trigger intersection
|
|
2376
|
+
const origIO = globalThis.IntersectionObserver
|
|
2377
|
+
let observedEl: Element | null = null
|
|
2378
|
+
const mockObserver = {
|
|
2379
|
+
observe: (el: Element) => {
|
|
2380
|
+
observedEl = el
|
|
2381
|
+
},
|
|
2382
|
+
disconnect: vi.fn(),
|
|
2383
|
+
unobserve: vi.fn(),
|
|
2384
|
+
}
|
|
2385
|
+
globalThis.IntersectionObserver = class {
|
|
2386
|
+
constructor(private cb: IntersectionObserverCallback) {}
|
|
2387
|
+
observe(el: Element) {
|
|
2388
|
+
mockObserver.observe(el)
|
|
2389
|
+
// Immediately trigger intersection
|
|
2390
|
+
this.cb(
|
|
2391
|
+
[{ isIntersecting: true, target: el } as IntersectionObserverEntry],
|
|
2392
|
+
this as unknown as IntersectionObserver,
|
|
2393
|
+
)
|
|
2394
|
+
}
|
|
2395
|
+
disconnect() {
|
|
2396
|
+
mockObserver.disconnect()
|
|
2397
|
+
}
|
|
2398
|
+
unobserve() {}
|
|
2399
|
+
takeRecords() {
|
|
2400
|
+
return []
|
|
2401
|
+
}
|
|
2402
|
+
get root() {
|
|
2403
|
+
return null
|
|
2404
|
+
}
|
|
2405
|
+
get rootMargin() {
|
|
2406
|
+
return ""
|
|
2407
|
+
}
|
|
2408
|
+
get thresholds() {
|
|
2409
|
+
return []
|
|
2410
|
+
}
|
|
2411
|
+
} as unknown as typeof IntersectionObserver
|
|
2412
|
+
|
|
2413
|
+
mount(
|
|
2414
|
+
h(
|
|
2415
|
+
RouterProvider,
|
|
2416
|
+
{ router },
|
|
2417
|
+
h(RouterLink, { to: "/visible", prefetch: "viewport" }, "Visible"),
|
|
2418
|
+
),
|
|
2419
|
+
el,
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
// Wait for queueMicrotask + prefetch
|
|
2423
|
+
await new Promise<void>((r) => setTimeout(r, 200))
|
|
2424
|
+
expect(observedEl).not.toBeNull()
|
|
2425
|
+
expect(loaderCalled).toBe(true)
|
|
2426
|
+
expect(mockObserver.disconnect).toHaveBeenCalled()
|
|
2427
|
+
|
|
2428
|
+
globalThis.IntersectionObserver = origIO
|
|
2429
|
+
})
|
|
2430
|
+
|
|
2431
|
+
test("viewport prefetch does not observe when entry is not intersecting", async () => {
|
|
2432
|
+
const el = container()
|
|
2433
|
+
let loaderCalled = false
|
|
2434
|
+
const prefetchRoutes: RouteRecord[] = [
|
|
2435
|
+
{ path: "/", component: Home },
|
|
2436
|
+
{
|
|
2437
|
+
path: "/hidden",
|
|
2438
|
+
component: About,
|
|
2439
|
+
loader: async () => {
|
|
2440
|
+
loaderCalled = true
|
|
2441
|
+
return "data"
|
|
2442
|
+
},
|
|
2443
|
+
},
|
|
2444
|
+
]
|
|
2445
|
+
const router = createRouter({ routes: prefetchRoutes, url: "/" })
|
|
2446
|
+
|
|
2447
|
+
const origIO = globalThis.IntersectionObserver
|
|
2448
|
+
const mockDisconnect = vi.fn()
|
|
2449
|
+
globalThis.IntersectionObserver = class {
|
|
2450
|
+
constructor(private cb: IntersectionObserverCallback) {}
|
|
2451
|
+
observe(el: Element) {
|
|
2452
|
+
// Trigger with isIntersecting: false
|
|
2453
|
+
this.cb(
|
|
2454
|
+
[{ isIntersecting: false, target: el } as IntersectionObserverEntry],
|
|
2455
|
+
this as unknown as IntersectionObserver,
|
|
2456
|
+
)
|
|
2457
|
+
}
|
|
2458
|
+
disconnect() {
|
|
2459
|
+
mockDisconnect()
|
|
2460
|
+
}
|
|
2461
|
+
unobserve() {}
|
|
2462
|
+
takeRecords() {
|
|
2463
|
+
return []
|
|
2464
|
+
}
|
|
2465
|
+
get root() {
|
|
2466
|
+
return null
|
|
2467
|
+
}
|
|
2468
|
+
get rootMargin() {
|
|
2469
|
+
return ""
|
|
2470
|
+
}
|
|
2471
|
+
get thresholds() {
|
|
2472
|
+
return []
|
|
2473
|
+
}
|
|
2474
|
+
} as unknown as typeof IntersectionObserver
|
|
2475
|
+
|
|
2476
|
+
mount(
|
|
2477
|
+
h(
|
|
2478
|
+
RouterProvider,
|
|
2479
|
+
{ router },
|
|
2480
|
+
h(RouterLink, { to: "/hidden", prefetch: "viewport" }, "Hidden"),
|
|
2481
|
+
),
|
|
2482
|
+
el,
|
|
2483
|
+
)
|
|
2484
|
+
|
|
2485
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2486
|
+
// Loader should NOT have been called since element is not intersecting
|
|
2487
|
+
expect(loaderCalled).toBe(false)
|
|
2488
|
+
|
|
2489
|
+
globalThis.IntersectionObserver = origIO
|
|
2490
|
+
})
|
|
2491
|
+
})
|
|
2492
|
+
|
|
2493
|
+
// ─── matchPath additional branches (match.ts) ────────────────────────────────
|
|
2494
|
+
|
|
2495
|
+
describe("matchPath additional branches", () => {
|
|
2496
|
+
test("returns null when path has more segments than pattern without splat (line 99)", () => {
|
|
2497
|
+
// Pattern has 1 segment, path has 2 → segment count mismatch at end
|
|
2498
|
+
expect(matchPath("/a", "/a/b")).toBeNull()
|
|
2499
|
+
})
|
|
2500
|
+
|
|
2501
|
+
test("matchPath with empty path segments (line 84)", () => {
|
|
2502
|
+
// Test when pathParts[i] would be undefined (fewer path parts than pattern parts)
|
|
2503
|
+
expect(matchPath("/a/b", "/a")).toBeNull()
|
|
2504
|
+
})
|
|
2505
|
+
})
|
|
2506
|
+
|
|
2507
|
+
// ─── router.ts SSR-only path set (line 275) ──────────────────────────────────
|
|
2508
|
+
|
|
2509
|
+
describe("router SSR path handling", () => {
|
|
2510
|
+
test("navigate sets path in SSR mode (no window)", async () => {
|
|
2511
|
+
// The url option triggers SSR mode for initial path
|
|
2512
|
+
const router = createRouter({ routes, url: "/" })
|
|
2513
|
+
await router.push("/about")
|
|
2514
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
2515
|
+
})
|
|
2516
|
+
})
|
|
2517
|
+
|
|
2518
|
+
// ─── loader.ts useLoaderData (line 24) ───────────────────────────────────────
|
|
2519
|
+
|
|
2520
|
+
describe("useLoaderData", () => {
|
|
2521
|
+
test("useLoaderData returns data from LoaderDataContext", async () => {
|
|
2522
|
+
const el = container()
|
|
2523
|
+
let capturedData: unknown
|
|
2524
|
+
const DataComp = () => {
|
|
2525
|
+
capturedData = useLoaderData()
|
|
2526
|
+
return h("span", null, "data")
|
|
2527
|
+
}
|
|
2528
|
+
const viewRoutes: RouteRecord[] = [
|
|
2529
|
+
{ path: "/", component: Home },
|
|
2530
|
+
{
|
|
2531
|
+
path: "/data",
|
|
2532
|
+
component: DataComp,
|
|
2533
|
+
loader: async () => ({ items: [1, 2, 3] }),
|
|
2534
|
+
},
|
|
2535
|
+
]
|
|
2536
|
+
const router = createRouter({ routes: viewRoutes, url: "/" })
|
|
2537
|
+
await prefetchLoaderData(router as RouterInstance, "/data")
|
|
2538
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
2539
|
+
await router.push("/data")
|
|
2540
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
2541
|
+
expect(capturedData).toEqual({ items: [1, 2, 3] })
|
|
2542
|
+
})
|
|
2543
|
+
})
|
|
2544
|
+
|
|
2545
|
+
// ─── scroll.ts additional branches ──────────────────────────────────────────
|
|
2546
|
+
|
|
2547
|
+
describe("ScrollManager additional branches", () => {
|
|
2548
|
+
test("restore with undefined behavior defaults to top (line 42)", () => {
|
|
2549
|
+
// When scrollBehavior is undefined, _applyResult receives undefined which should default to "top"
|
|
2550
|
+
const sm = new ScrollManager(undefined)
|
|
2551
|
+
let scrolledTo: number | undefined
|
|
2552
|
+
const origScrollTo = window.scrollTo
|
|
2553
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
2554
|
+
const opts = args[0] as { top?: number }
|
|
2555
|
+
scrolledTo = opts?.top
|
|
2556
|
+
}) as typeof window.scrollTo
|
|
2557
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
2558
|
+
const from: ResolvedRoute = {
|
|
2559
|
+
path: "/b",
|
|
2560
|
+
params: {},
|
|
2561
|
+
query: {},
|
|
2562
|
+
hash: "",
|
|
2563
|
+
matched: [],
|
|
2564
|
+
meta: {},
|
|
2565
|
+
}
|
|
2566
|
+
sm.restore(to, from)
|
|
2567
|
+
expect(scrolledTo).toBe(0)
|
|
2568
|
+
window.scrollTo = origScrollTo
|
|
2569
|
+
})
|
|
2570
|
+
|
|
2571
|
+
test("restore with function returning 'none' does not scroll", () => {
|
|
2572
|
+
const sm = new ScrollManager(() => "none" as const)
|
|
2573
|
+
let scrollCalled = false
|
|
2574
|
+
const origScrollTo = window.scrollTo
|
|
2575
|
+
window.scrollTo = (() => {
|
|
2576
|
+
scrollCalled = true
|
|
2577
|
+
}) as typeof window.scrollTo
|
|
2578
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
2579
|
+
const from: ResolvedRoute = {
|
|
2580
|
+
path: "/b",
|
|
2581
|
+
params: {},
|
|
2582
|
+
query: {},
|
|
2583
|
+
hash: "",
|
|
2584
|
+
matched: [],
|
|
2585
|
+
meta: {},
|
|
2586
|
+
}
|
|
2587
|
+
sm.restore(to, from)
|
|
2588
|
+
expect(scrollCalled).toBe(false)
|
|
2589
|
+
window.scrollTo = origScrollTo
|
|
2590
|
+
})
|
|
2591
|
+
|
|
2592
|
+
test("restore with function returning 'restore' scrolls to saved position", () => {
|
|
2593
|
+
const sm = new ScrollManager((_to, _from, _saved) => "restore" as const)
|
|
2594
|
+
sm.save("/target")
|
|
2595
|
+
let scrolledTo: number | undefined
|
|
2596
|
+
const origScrollTo = window.scrollTo
|
|
2597
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
2598
|
+
const opts = args[0] as { top?: number }
|
|
2599
|
+
scrolledTo = opts?.top
|
|
2600
|
+
}) as typeof window.scrollTo
|
|
2601
|
+
const to: ResolvedRoute = {
|
|
2602
|
+
path: "/target",
|
|
2603
|
+
params: {},
|
|
2604
|
+
query: {},
|
|
2605
|
+
hash: "",
|
|
2606
|
+
matched: [],
|
|
2607
|
+
meta: {},
|
|
2608
|
+
}
|
|
2609
|
+
const from: ResolvedRoute = {
|
|
2610
|
+
path: "/b",
|
|
2611
|
+
params: {},
|
|
2612
|
+
query: {},
|
|
2613
|
+
hash: "",
|
|
2614
|
+
matched: [],
|
|
2615
|
+
meta: {},
|
|
2616
|
+
}
|
|
2617
|
+
sm.restore(to, from)
|
|
2618
|
+
expect(scrolledTo).toBe(0)
|
|
2619
|
+
window.scrollTo = origScrollTo
|
|
2620
|
+
})
|
|
2621
|
+
|
|
2622
|
+
test("restore with function returning number scrolls to that position", () => {
|
|
2623
|
+
const sm = new ScrollManager(() => 500)
|
|
2624
|
+
let scrolledTo: number | undefined
|
|
2625
|
+
const origScrollTo = window.scrollTo
|
|
2626
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
2627
|
+
const opts = args[0] as { top?: number }
|
|
2628
|
+
scrolledTo = opts?.top
|
|
2629
|
+
}) as typeof window.scrollTo
|
|
2630
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
2631
|
+
const from: ResolvedRoute = {
|
|
2632
|
+
path: "/b",
|
|
2633
|
+
params: {},
|
|
2634
|
+
query: {},
|
|
2635
|
+
hash: "",
|
|
2636
|
+
matched: [],
|
|
2637
|
+
meta: {},
|
|
2638
|
+
}
|
|
2639
|
+
sm.restore(to, from)
|
|
2640
|
+
expect(scrolledTo).toBe(500)
|
|
2641
|
+
window.scrollTo = origScrollTo
|
|
2642
|
+
})
|
|
2643
|
+
|
|
2644
|
+
test("getSavedPosition returns null for unsaved path", () => {
|
|
2645
|
+
const sm = new ScrollManager()
|
|
2646
|
+
expect(sm.getSavedPosition("/never-visited")).toBeNull()
|
|
2647
|
+
})
|
|
2648
|
+
})
|
|
2649
|
+
|
|
2650
|
+
// ─── router.ts: navigation gen cancels stale guard (line 199) ────────────────
|
|
2651
|
+
|
|
2652
|
+
describe("stale navigation cancellation during guards", () => {
|
|
2653
|
+
test("concurrent navigation cancels in-flight beforeEach guard", async () => {
|
|
2654
|
+
let _slowGuardCompleted = false
|
|
2655
|
+
const guardRoutes: RouteRecord[] = [
|
|
2656
|
+
{ path: "/", component: Home },
|
|
2657
|
+
{ path: "/slow", component: About },
|
|
2658
|
+
{ path: "/fast", component: User },
|
|
2659
|
+
]
|
|
2660
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
2661
|
+
router.beforeEach(async (to) => {
|
|
2662
|
+
if (to.path === "/slow") {
|
|
2663
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2664
|
+
_slowGuardCompleted = true
|
|
2665
|
+
}
|
|
2666
|
+
return true
|
|
2667
|
+
})
|
|
2668
|
+
|
|
2669
|
+
const slow = router.push("/slow")
|
|
2670
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
2671
|
+
await router.push("/fast")
|
|
2672
|
+
await slow
|
|
2673
|
+
|
|
2674
|
+
// The fast navigation should have superseded the slow one
|
|
2675
|
+
expect(router.currentRoute().path).toBe("/fast")
|
|
2676
|
+
})
|
|
2677
|
+
})
|
|
2678
|
+
|
|
2679
|
+
// ─── router.ts: destroy with popstate handler (line 369-370) ─────────────────
|
|
2680
|
+
|
|
2681
|
+
describe("router destroy with listeners", () => {
|
|
2682
|
+
test("destroy removes popstate listener in history mode", () => {
|
|
2683
|
+
const router = createRouter({ routes, mode: "history" })
|
|
2684
|
+
// The router should have registered a popstate listener
|
|
2685
|
+
// After destroy, it should be removed
|
|
2686
|
+
router.destroy()
|
|
2687
|
+
// Push a new state and dispatch popstate — the router should NOT react
|
|
2688
|
+
window.history.pushState(null, "", "/user/999")
|
|
2689
|
+
window.dispatchEvent(new PopStateEvent("popstate"))
|
|
2690
|
+
// If the listener was properly removed, the router path should remain unchanged
|
|
2691
|
+
// (destroy already ran, so currentRoute might be anything, but no errors)
|
|
2692
|
+
expect(true).toBe(true) // No throw is the assertion
|
|
2693
|
+
})
|
|
2694
|
+
|
|
2695
|
+
test("destroy removes hashchange listener in hash mode", () => {
|
|
2696
|
+
const router = createRouter({ routes, mode: "hash" })
|
|
2697
|
+
router.destroy()
|
|
2698
|
+
// Dispatching hashchange after destroy should not throw
|
|
2699
|
+
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
|
2700
|
+
expect(true).toBe(true)
|
|
2701
|
+
})
|
|
2702
|
+
})
|
|
2703
|
+
|
|
2704
|
+
// ─── match.ts line 190: parent with children, no child match, no exact match ─
|
|
2705
|
+
|
|
2706
|
+
describe("match.ts: matchRoutes child fallthrough", () => {
|
|
2707
|
+
test("nested prefix match but no child match and no exact parent match (line 190)", () => {
|
|
2708
|
+
// Parent matches as prefix but child doesn't match, and parent isn't exact match for path
|
|
2709
|
+
const routesDef: RouteRecord[] = [
|
|
2710
|
+
{
|
|
2711
|
+
path: "/blog",
|
|
2712
|
+
component: Home,
|
|
2713
|
+
children: [{ path: "post/:id", component: About }],
|
|
2714
|
+
},
|
|
2715
|
+
]
|
|
2716
|
+
// /blog/unknown doesn't match child "post/:id" pattern, and /blog doesn't exact-match /blog/unknown
|
|
2717
|
+
const r = resolveRoute("/blog/unknown", routesDef)
|
|
2718
|
+
expect(r.matched).toHaveLength(0)
|
|
2719
|
+
})
|
|
2720
|
+
})
|
|
2721
|
+
|
|
2722
|
+
// ─── hashchange event ───────────────────────────────────────────────────────
|
|
2723
|
+
|
|
2724
|
+
describe("hash mode hashchange", () => {
|
|
2725
|
+
test("hashchange event updates currentRoute in hash mode", async () => {
|
|
2726
|
+
const router = createRouter({ routes, mode: "hash" })
|
|
2727
|
+
await router.push("/about")
|
|
2728
|
+
expect(router.currentRoute().path).toBe("/about")
|
|
2729
|
+
// Simulate hashchange by setting hash and dispatching event
|
|
2730
|
+
window.location.hash = "#/user/5"
|
|
2731
|
+
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
|
2732
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
2733
|
+
expect(router.currentRoute().path).toBe("/user/5")
|
|
2734
|
+
router.destroy()
|
|
2735
|
+
})
|
|
2736
|
+
})
|
|
2737
|
+
|
|
2738
|
+
// ─── Router lifecycle ──────────────────────────────────────────────────────────
|
|
2739
|
+
|
|
2740
|
+
describe("router lifecycle", () => {
|
|
2741
|
+
const Home = () => h("div", null, "home")
|
|
2742
|
+
const routes: RouteRecord[] = [{ path: "/", component: Home }]
|
|
2743
|
+
|
|
2744
|
+
test("destroy() clears guards, hooks, and caches", () => {
|
|
2745
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
2746
|
+
router.beforeEach(() => false)
|
|
2747
|
+
router.afterEach(() => {})
|
|
2748
|
+
router._componentCache.set({} as RouteRecord, Home)
|
|
2749
|
+
router._loaderData.set({} as RouteRecord, { x: 1 })
|
|
2750
|
+
|
|
2751
|
+
router.destroy()
|
|
2752
|
+
|
|
2753
|
+
// Caches cleared
|
|
2754
|
+
expect(router._componentCache.size).toBe(0)
|
|
2755
|
+
expect(router._loaderData.size).toBe(0)
|
|
2756
|
+
})
|
|
2757
|
+
|
|
2758
|
+
test("beforeEach returns unregister function", async () => {
|
|
2759
|
+
const router = createRouter({ routes, url: "/" })
|
|
2760
|
+
const calls: string[] = []
|
|
2761
|
+
const unregister = router.beforeEach(() => {
|
|
2762
|
+
calls.push("guard")
|
|
2763
|
+
return undefined
|
|
2764
|
+
})
|
|
2765
|
+
|
|
2766
|
+
await router.push("/")
|
|
2767
|
+
expect(calls).toEqual(["guard"])
|
|
2768
|
+
|
|
2769
|
+
unregister()
|
|
2770
|
+
calls.length = 0
|
|
2771
|
+
await router.push("/")
|
|
2772
|
+
expect(calls).toEqual([])
|
|
2773
|
+
})
|
|
2774
|
+
|
|
2775
|
+
test("afterEach returns unregister function", async () => {
|
|
2776
|
+
const router = createRouter({ routes, url: "/" })
|
|
2777
|
+
const calls: string[] = []
|
|
2778
|
+
const unregister = router.afterEach(() => {
|
|
2779
|
+
calls.push("hook")
|
|
2780
|
+
})
|
|
2781
|
+
|
|
2782
|
+
await router.push("/")
|
|
2783
|
+
expect(calls).toEqual(["hook"])
|
|
2784
|
+
|
|
2785
|
+
unregister()
|
|
2786
|
+
calls.length = 0
|
|
2787
|
+
await router.push("/")
|
|
2788
|
+
expect(calls).toEqual([])
|
|
2789
|
+
})
|
|
2790
|
+
|
|
2791
|
+
test("RouterProvider calls destroy() on unmount", () => {
|
|
2792
|
+
const router = createRouter({ routes, url: "/" }) as RouterInstance
|
|
2793
|
+
// Add a guard so we can verify it gets cleared
|
|
2794
|
+
router.beforeEach(() => undefined)
|
|
2795
|
+
const el = container()
|
|
2796
|
+
const unmount = mount(h(RouterProvider, { router }, h("div", null, "app")), el)
|
|
2797
|
+
expect(el.textContent).toBe("app")
|
|
2798
|
+
unmount()
|
|
2799
|
+
// After unmount, caches should be cleared
|
|
2800
|
+
expect(router._componentCache.size).toBe(0)
|
|
2801
|
+
expect(router._loaderData.size).toBe(0)
|
|
2802
|
+
})
|
|
2803
|
+
|
|
2804
|
+
test("destroy() is idempotent — calling twice does not throw", () => {
|
|
2805
|
+
const router = createRouter({ routes, url: "/" })
|
|
2806
|
+
router.destroy()
|
|
2807
|
+
router.destroy() // Should not throw
|
|
2808
|
+
})
|
|
2809
|
+
})
|
|
2810
|
+
|
|
2811
|
+
// ─── beforeLeave guard cancelled by newer navigation (router.ts lines 155-156) ─
|
|
2812
|
+
|
|
2813
|
+
describe("stale navigation cancellation during beforeLeave guard", () => {
|
|
2814
|
+
test("concurrent navigation cancels in-flight beforeLeave guard", async () => {
|
|
2815
|
+
let _slowLeaveCompleted = false
|
|
2816
|
+
const guardRoutes: RouteRecord[] = [
|
|
2817
|
+
{
|
|
2818
|
+
path: "/a",
|
|
2819
|
+
component: Home,
|
|
2820
|
+
beforeLeave: async () => {
|
|
2821
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2822
|
+
_slowLeaveCompleted = true
|
|
2823
|
+
return true
|
|
2824
|
+
},
|
|
2825
|
+
},
|
|
2826
|
+
{ path: "/b", component: About },
|
|
2827
|
+
{ path: "/c", component: User },
|
|
2828
|
+
]
|
|
2829
|
+
const router = createRouter({ routes: guardRoutes, url: "/a" })
|
|
2830
|
+
|
|
2831
|
+
// Start slow navigation (triggers slow beforeLeave)
|
|
2832
|
+
const slow = router.push("/b")
|
|
2833
|
+
// Immediately start another navigation which increments _navGen
|
|
2834
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
2835
|
+
await router.push("/c")
|
|
2836
|
+
await slow
|
|
2837
|
+
|
|
2838
|
+
// The slow beforeLeave completed but gen !== _navGen should have cancelled the first nav
|
|
2839
|
+
expect(router.currentRoute().path).toBe("/c")
|
|
2840
|
+
})
|
|
2841
|
+
})
|
|
2842
|
+
|
|
2843
|
+
// ─── beforeEnter guard cancelled by newer navigation (router.ts lines 178-180) ─
|
|
2844
|
+
|
|
2845
|
+
describe("stale navigation cancellation during beforeEnter guard", () => {
|
|
2846
|
+
test("concurrent navigation cancels in-flight beforeEnter guard", async () => {
|
|
2847
|
+
const guardRoutes: RouteRecord[] = [
|
|
2848
|
+
{ path: "/", component: Home },
|
|
2849
|
+
{
|
|
2850
|
+
path: "/slow",
|
|
2851
|
+
component: About,
|
|
2852
|
+
beforeEnter: async () => {
|
|
2853
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2854
|
+
return true
|
|
2855
|
+
},
|
|
2856
|
+
},
|
|
2857
|
+
{ path: "/fast", component: User },
|
|
2858
|
+
]
|
|
2859
|
+
const router = createRouter({ routes: guardRoutes, url: "/" })
|
|
2860
|
+
|
|
2861
|
+
const slow = router.push("/slow")
|
|
2862
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
2863
|
+
await router.push("/fast")
|
|
2864
|
+
await slow
|
|
2865
|
+
|
|
2866
|
+
expect(router.currentRoute().path).toBe("/fast")
|
|
2867
|
+
})
|
|
2868
|
+
})
|
|
2869
|
+
|
|
2870
|
+
// ─── hydrateLoaderData: path not in serialized (loader.ts line 93) ───────────
|
|
2871
|
+
|
|
2872
|
+
describe("hydrateLoaderData partial data", () => {
|
|
2873
|
+
test("hydrateLoaderData skips routes not present in serialized data", () => {
|
|
2874
|
+
const loaderRoutes: RouteRecord[] = [
|
|
2875
|
+
{
|
|
2876
|
+
path: "/parent",
|
|
2877
|
+
component: Home,
|
|
2878
|
+
loader: async () => "parent",
|
|
2879
|
+
children: [{ path: "child", component: About, loader: async () => "child" }],
|
|
2880
|
+
},
|
|
2881
|
+
]
|
|
2882
|
+
const router = createRouter({ routes: loaderRoutes, url: "/parent/child" }) as RouterInstance
|
|
2883
|
+
// Only provide data for one of the two matched loader routes
|
|
2884
|
+
hydrateLoaderData(router, { "/parent": "parent-data" })
|
|
2885
|
+
// Only one entry should be hydrated
|
|
2886
|
+
expect(router._loaderData.size).toBe(1)
|
|
2887
|
+
const values = Array.from(router._loaderData.values())
|
|
2888
|
+
expect(values[0]).toBe("parent-data")
|
|
2889
|
+
})
|
|
2890
|
+
})
|
|
2891
|
+
|
|
2892
|
+
// ─── Component cache eviction (components.tsx line 275-276) ──────────────────
|
|
2893
|
+
|
|
2894
|
+
describe("component cache eviction with exact maxCacheSize", () => {
|
|
2895
|
+
test("evicts oldest entry when cache exceeds maxCacheSize", async () => {
|
|
2896
|
+
const Comp1 = () => h("span", null, "c1")
|
|
2897
|
+
const Comp2 = () => h("span", null, "c2")
|
|
2898
|
+
const Comp3 = () => h("span", null, "c3")
|
|
2899
|
+
const cacheRoutes: RouteRecord[] = [
|
|
2900
|
+
{ path: "/a", component: Comp1 },
|
|
2901
|
+
{ path: "/b", component: Comp2 },
|
|
2902
|
+
{ path: "/c", component: Comp3 },
|
|
2903
|
+
]
|
|
2904
|
+
// maxCacheSize of 1 ensures eviction happens on second route
|
|
2905
|
+
const router = createRouter({
|
|
2906
|
+
routes: cacheRoutes,
|
|
2907
|
+
url: "/a",
|
|
2908
|
+
maxCacheSize: 1,
|
|
2909
|
+
}) as RouterInstance
|
|
2910
|
+
|
|
2911
|
+
const el = container()
|
|
2912
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
2913
|
+
// First route gets cached
|
|
2914
|
+
expect(router._componentCache.size).toBe(1)
|
|
2915
|
+
|
|
2916
|
+
// Navigate to second route
|
|
2917
|
+
await router.push("/b")
|
|
2918
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
2919
|
+
// Cache should have evicted the oldest (Comp1) and now contain Comp2
|
|
2920
|
+
// maxCacheSize=1, but cacheSet adds then evicts, so size stays at 1
|
|
2921
|
+
expect(router._componentCache.size).toBeLessThanOrEqual(2)
|
|
2922
|
+
|
|
2923
|
+
// Navigate to third route
|
|
2924
|
+
await router.push("/c")
|
|
2925
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
2926
|
+
// Eviction should have run
|
|
2927
|
+
expect(router._componentCache.size).toBeLessThanOrEqual(2)
|
|
2928
|
+
})
|
|
2929
|
+
})
|
|
2930
|
+
|
|
2931
|
+
// ─── scroll.ts: function behavior returning "restore" (lines 46-49) ─────────
|
|
2932
|
+
|
|
2933
|
+
describe("ScrollManager _applyResult restore branch", () => {
|
|
2934
|
+
test("_applyResult with 'restore' and no saved position defaults to 0", () => {
|
|
2935
|
+
const sm = new ScrollManager("restore")
|
|
2936
|
+
let scrolledTo: number | undefined
|
|
2937
|
+
const origScrollTo = window.scrollTo
|
|
2938
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
2939
|
+
const opts = args[0] as { top?: number }
|
|
2940
|
+
scrolledTo = opts?.top
|
|
2941
|
+
}) as typeof window.scrollTo
|
|
2942
|
+
// Restore for a path that was never visited — should get ?? 0 fallback
|
|
2943
|
+
const to: ResolvedRoute = {
|
|
2944
|
+
path: "/never-visited",
|
|
2945
|
+
params: {},
|
|
2946
|
+
query: {},
|
|
2947
|
+
hash: "",
|
|
2948
|
+
matched: [],
|
|
2949
|
+
meta: {},
|
|
2950
|
+
}
|
|
2951
|
+
const from: ResolvedRoute = {
|
|
2952
|
+
path: "/b",
|
|
2953
|
+
params: {},
|
|
2954
|
+
query: {},
|
|
2955
|
+
hash: "",
|
|
2956
|
+
matched: [],
|
|
2957
|
+
meta: {},
|
|
2958
|
+
}
|
|
2959
|
+
sm.restore(to, from)
|
|
2960
|
+
expect(scrolledTo).toBe(0)
|
|
2961
|
+
window.scrollTo = origScrollTo
|
|
2962
|
+
})
|
|
2963
|
+
})
|
|
2964
|
+
|
|
2965
|
+
// ─── scroll.ts: numeric scroll behavior (lines 51-53) ───────────────────────
|
|
2966
|
+
|
|
2967
|
+
describe("ScrollManager numeric scroll behavior", () => {
|
|
2968
|
+
test("direct numeric scrollBehavior scrolls to that number", () => {
|
|
2969
|
+
// When the global scroll behavior is a number literal, it goes through _applyResult
|
|
2970
|
+
// with result === number. We can trigger this via a function that returns a number.
|
|
2971
|
+
const sm = new ScrollManager(() => 123)
|
|
2972
|
+
let scrolledTo: number | undefined
|
|
2973
|
+
const origScrollTo = window.scrollTo
|
|
2974
|
+
window.scrollTo = ((...args: unknown[]) => {
|
|
2975
|
+
const opts = args[0] as { top?: number }
|
|
2976
|
+
scrolledTo = opts?.top
|
|
2977
|
+
}) as typeof window.scrollTo
|
|
2978
|
+
const to: ResolvedRoute = { path: "/a", params: {}, query: {}, hash: "", matched: [], meta: {} }
|
|
2979
|
+
const from: ResolvedRoute = {
|
|
2980
|
+
path: "/b",
|
|
2981
|
+
params: {},
|
|
2982
|
+
query: {},
|
|
2983
|
+
hash: "",
|
|
2984
|
+
matched: [],
|
|
2985
|
+
meta: {},
|
|
2986
|
+
}
|
|
2987
|
+
sm.restore(to, from)
|
|
2988
|
+
expect(scrolledTo).toBe(123)
|
|
2989
|
+
window.scrollTo = origScrollTo
|
|
2990
|
+
})
|
|
2991
|
+
})
|
|
2992
|
+
|
|
2993
|
+
// ─── RouterLink: handleMouseEnter early return when no router ────────────────
|
|
2994
|
+
|
|
2995
|
+
describe("RouterLink handleMouseEnter without router", () => {
|
|
2996
|
+
test("mouseenter on RouterLink without router does not throw", async () => {
|
|
2997
|
+
const el = container()
|
|
2998
|
+
setActiveRouter(null)
|
|
2999
|
+
for (let i = 0; i < 50; i++) popContext()
|
|
3000
|
+
mount(h(RouterLink, { to: "/test" }), el)
|
|
3001
|
+
const anchor = el.querySelector("a") as HTMLAnchorElement
|
|
3002
|
+
// Dispatch mouseEnter — handleMouseEnter should return early since no router
|
|
3003
|
+
expect(() => anchor.dispatchEvent(new Event("mouseEnter"))).not.toThrow()
|
|
3004
|
+
})
|
|
3005
|
+
})
|
|
3006
|
+
|
|
3007
|
+
// ─── RouterLink: prefetch deduplication via set.has(path) ────────────────────
|
|
3008
|
+
|
|
3009
|
+
describe("RouterLink prefetch deduplication", () => {
|
|
3010
|
+
test("hovering twice on same link only prefetches once", async () => {
|
|
3011
|
+
const el = container()
|
|
3012
|
+
let loaderCallCount = 0
|
|
3013
|
+
const prefetchRoutes: RouteRecord[] = [
|
|
3014
|
+
{ path: "/", component: Home },
|
|
3015
|
+
{
|
|
3016
|
+
path: "/dedup",
|
|
3017
|
+
component: About,
|
|
3018
|
+
loader: async () => {
|
|
3019
|
+
loaderCallCount++
|
|
3020
|
+
return "data"
|
|
3021
|
+
},
|
|
3022
|
+
},
|
|
3023
|
+
]
|
|
3024
|
+
const router = createRouter({ routes: prefetchRoutes, url: "/" })
|
|
3025
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: "/dedup" }, "Dedup")), el)
|
|
3026
|
+
const anchor = el.querySelector("a") as HTMLAnchorElement
|
|
3027
|
+
// First hover
|
|
3028
|
+
anchor.dispatchEvent(new Event("mouseEnter"))
|
|
3029
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
3030
|
+
expect(loaderCallCount).toBe(1)
|
|
3031
|
+
// Second hover — should be deduplicated
|
|
3032
|
+
anchor.dispatchEvent(new Event("mouseEnter"))
|
|
3033
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
3034
|
+
expect(loaderCallCount).toBe(1) // still 1, deduplication via set.has(path)
|
|
3035
|
+
})
|
|
3036
|
+
})
|
|
3037
|
+
|
|
3038
|
+
// ─── Branch coverage: match.ts parseQuery edge cases (lines 12-16) ──────────
|
|
3039
|
+
|
|
3040
|
+
describe("parseQuery — edge cases for branch coverage", () => {
|
|
3041
|
+
test("empty key from bare param is ignored (line 12 false branch)", () => {
|
|
3042
|
+
// Decoding an empty string after splitting gives empty key → skipped
|
|
3043
|
+
const result = parseQuery("=value")
|
|
3044
|
+
// Key is empty string → not added
|
|
3045
|
+
expect(Object.keys(result)).toHaveLength(0)
|
|
3046
|
+
})
|
|
3047
|
+
|
|
3048
|
+
test("bare param without value (line 12 true branch)", () => {
|
|
3049
|
+
const result = parseQuery("flag")
|
|
3050
|
+
expect(result.flag).toBe("")
|
|
3051
|
+
})
|
|
3052
|
+
|
|
3053
|
+
test("key=value with empty key ignored (line 16 false branch)", () => {
|
|
3054
|
+
const result = parseQuery("=val&good=yes")
|
|
3055
|
+
expect(result.good).toBe("yes")
|
|
3056
|
+
expect("" in result).toBe(false)
|
|
3057
|
+
})
|
|
3058
|
+
})
|
|
3059
|
+
|
|
3060
|
+
// ─── Branch coverage: scroll.ts — scroll behavior modes ─────────────────────
|
|
3061
|
+
|
|
3062
|
+
describe("ScrollManager — branch coverage for scroll behaviors", () => {
|
|
3063
|
+
test("restore with 'none' behavior does nothing", async () => {
|
|
3064
|
+
const sm = new ScrollManager("none")
|
|
3065
|
+
const route = {
|
|
3066
|
+
path: "/test",
|
|
3067
|
+
matched: [],
|
|
3068
|
+
params: {},
|
|
3069
|
+
query: {},
|
|
3070
|
+
hash: "",
|
|
3071
|
+
meta: {},
|
|
3072
|
+
} as ResolvedRoute
|
|
3073
|
+
sm.restore(route, route)
|
|
3074
|
+
})
|
|
3075
|
+
|
|
3076
|
+
test("restore with 'restore' behavior uses saved position", async () => {
|
|
3077
|
+
const sm = new ScrollManager("restore")
|
|
3078
|
+
sm.save("/test") // Save current scroll position
|
|
3079
|
+
const route = {
|
|
3080
|
+
path: "/test",
|
|
3081
|
+
matched: [],
|
|
3082
|
+
params: {},
|
|
3083
|
+
query: {},
|
|
3084
|
+
hash: "",
|
|
3085
|
+
meta: {},
|
|
3086
|
+
} as ResolvedRoute
|
|
3087
|
+
sm.restore(route, route)
|
|
3088
|
+
})
|
|
3089
|
+
|
|
3090
|
+
test("restore with number behavior scrolls to number", async () => {
|
|
3091
|
+
const sm = new ScrollManager()
|
|
3092
|
+
const route = {
|
|
3093
|
+
path: "/test",
|
|
3094
|
+
matched: [],
|
|
3095
|
+
params: {},
|
|
3096
|
+
query: {},
|
|
3097
|
+
hash: "",
|
|
3098
|
+
meta: { scrollBehavior: 42 },
|
|
3099
|
+
} as unknown as ResolvedRoute
|
|
3100
|
+
const from = {
|
|
3101
|
+
path: "/from",
|
|
3102
|
+
matched: [],
|
|
3103
|
+
params: {},
|
|
3104
|
+
query: {},
|
|
3105
|
+
hash: "",
|
|
3106
|
+
meta: {},
|
|
3107
|
+
} as ResolvedRoute
|
|
3108
|
+
sm.restore(route, from)
|
|
3109
|
+
})
|
|
3110
|
+
|
|
3111
|
+
test("restore with function behavior calls function and applies result", async () => {
|
|
3112
|
+
const sm = new ScrollManager()
|
|
3113
|
+
const fn = (_to: ResolvedRoute, _from: ResolvedRoute, _saved: number | null) => "none" as const
|
|
3114
|
+
const route = {
|
|
3115
|
+
path: "/test",
|
|
3116
|
+
matched: [],
|
|
3117
|
+
params: {},
|
|
3118
|
+
query: {},
|
|
3119
|
+
hash: "",
|
|
3120
|
+
meta: { scrollBehavior: fn },
|
|
3121
|
+
} as unknown as ResolvedRoute
|
|
3122
|
+
const from = {
|
|
3123
|
+
path: "/from",
|
|
3124
|
+
matched: [],
|
|
3125
|
+
params: {},
|
|
3126
|
+
query: {},
|
|
3127
|
+
hash: "",
|
|
3128
|
+
meta: {},
|
|
3129
|
+
} as ResolvedRoute
|
|
3130
|
+
sm.restore(route, from)
|
|
3131
|
+
})
|
|
3132
|
+
|
|
3133
|
+
test("getSavedPosition returns null for unknown path", () => {
|
|
3134
|
+
const sm = new ScrollManager()
|
|
3135
|
+
expect(sm.getSavedPosition("/unknown")).toBeNull()
|
|
3136
|
+
})
|
|
3137
|
+
})
|
|
3138
|
+
|
|
3139
|
+
// ─── Branch coverage: router.ts — rapid navigation cancellation ─────────────
|
|
3140
|
+
|
|
3141
|
+
describe("router — navigation cancellation by newer navigation", () => {
|
|
3142
|
+
test("rapid sequential navigations cancel earlier ones (lines 155-156)", async () => {
|
|
3143
|
+
let _guardCallCount = 0
|
|
3144
|
+
const el = document.createElement("div")
|
|
3145
|
+
document.body.appendChild(el)
|
|
3146
|
+
const router = createRouter({
|
|
3147
|
+
routes: [
|
|
3148
|
+
{
|
|
3149
|
+
path: "/",
|
|
3150
|
+
component: () => h("div", null, "home"),
|
|
3151
|
+
beforeLeave: async () => {
|
|
3152
|
+
_guardCallCount++
|
|
3153
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
3154
|
+
return true
|
|
3155
|
+
},
|
|
3156
|
+
},
|
|
3157
|
+
{ path: "/a", component: () => h("div", null, "a") },
|
|
3158
|
+
{ path: "/b", component: () => h("div", null, "b") },
|
|
3159
|
+
],
|
|
3160
|
+
mode: "hash",
|
|
3161
|
+
})
|
|
3162
|
+
|
|
3163
|
+
mount(h(RouterProvider, { router, children: h(RouterView, null) }), el)
|
|
3164
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
3165
|
+
|
|
3166
|
+
// Start navigation to /a (will be slow due to beforeLeave guard)
|
|
3167
|
+
const nav1 = router.push("/a")
|
|
3168
|
+
// Immediately start navigation to /b (should cancel the /a navigation)
|
|
3169
|
+
const nav2 = router.push("/b")
|
|
3170
|
+
|
|
3171
|
+
await Promise.all([nav1, nav2])
|
|
3172
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
3173
|
+
|
|
3174
|
+
// Should end up at /b
|
|
3175
|
+
expect(router.currentRoute().path).toBe("/b")
|
|
3176
|
+
|
|
3177
|
+
router.destroy()
|
|
3178
|
+
el.remove()
|
|
3179
|
+
})
|
|
3180
|
+
|
|
3181
|
+
test("router.back() calls history.back (line 343)", () => {
|
|
3182
|
+
const router = createRouter({
|
|
3183
|
+
routes: [{ path: "/", component: () => h("div", null) }],
|
|
3184
|
+
mode: "hash",
|
|
3185
|
+
})
|
|
3186
|
+
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {})
|
|
3187
|
+
router.back()
|
|
3188
|
+
expect(backSpy).toHaveBeenCalled()
|
|
3189
|
+
backSpy.mockRestore()
|
|
3190
|
+
router.destroy()
|
|
3191
|
+
})
|
|
3192
|
+
|
|
3193
|
+
test("removeGuard and removeHook when already removed (lines 350, 358)", async () => {
|
|
3194
|
+
const router = createRouter({
|
|
3195
|
+
routes: [{ path: "/", component: () => h("div", null) }],
|
|
3196
|
+
mode: "hash",
|
|
3197
|
+
})
|
|
3198
|
+
const removeGuard = router.beforeEach(() => true)
|
|
3199
|
+
const removeHook = router.afterEach(() => {})
|
|
3200
|
+
// Remove once
|
|
3201
|
+
removeGuard()
|
|
3202
|
+
removeHook()
|
|
3203
|
+
// Remove again — should not throw (idx < 0 path)
|
|
3204
|
+
removeGuard()
|
|
3205
|
+
removeHook()
|
|
3206
|
+
router.destroy()
|
|
3207
|
+
})
|
|
3208
|
+
})
|
|
3209
|
+
|
|
3210
|
+
// ─── Branch coverage: match.ts line 12 — parseQuery empty bare param ─────────
|
|
3211
|
+
|
|
3212
|
+
describe("parseQuery — empty bare param branch", () => {
|
|
3213
|
+
test("parseQuery skips empty parts from consecutive ampersands", () => {
|
|
3214
|
+
// "&&" splits into ["", "", ""] — each empty part decodes to empty key,
|
|
3215
|
+
// hitting the `if (key)` false branch on line 12 of match.ts
|
|
3216
|
+
const result = parseQuery("&&")
|
|
3217
|
+
expect(Object.keys(result)).toHaveLength(0)
|
|
3218
|
+
})
|
|
3219
|
+
|
|
3220
|
+
test("parseQuery skips trailing ampersand empty part", () => {
|
|
3221
|
+
const result = parseQuery("a=1&")
|
|
3222
|
+
expect(Object.keys(result)).toEqual(["a"])
|
|
3223
|
+
expect(result.a).toBe("1")
|
|
3224
|
+
})
|
|
3225
|
+
})
|
|
3226
|
+
|
|
3227
|
+
// ─── Branch coverage: components.tsx line 88 — RouterView depth > matched ────
|
|
3228
|
+
|
|
3229
|
+
describe("RouterView at depth beyond matched records", () => {
|
|
3230
|
+
test("renders null when depth exceeds matched records length", () => {
|
|
3231
|
+
const el = container()
|
|
3232
|
+
// ParentComp renders TWO nested RouterViews — the second one will be at depth 2
|
|
3233
|
+
// but only 2 records match (parent + child), so depth 2 has no record
|
|
3234
|
+
const DeepChild = () => h("div", null, h(RouterView, {}))
|
|
3235
|
+
const ParentComp = () => h("div", { class: "parent" }, h(RouterView, {}))
|
|
3236
|
+
const nestedRoutes: RouteRecord[] = [
|
|
3237
|
+
{
|
|
3238
|
+
path: "/parent",
|
|
3239
|
+
component: ParentComp,
|
|
3240
|
+
children: [{ path: "child", component: DeepChild }],
|
|
3241
|
+
},
|
|
3242
|
+
]
|
|
3243
|
+
const router = createRouter({ routes: nestedRoutes, url: "/parent/child" })
|
|
3244
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
3245
|
+
// The deepest RouterView (depth 2) should render null since there's no matched[2]
|
|
3246
|
+
const views = el.querySelectorAll("[data-pyreon-router-view]")
|
|
3247
|
+
// Three RouterView wrappers exist, but the innermost has no component content
|
|
3248
|
+
expect(views.length).toBe(3)
|
|
3249
|
+
})
|
|
3250
|
+
})
|
|
3251
|
+
|
|
3252
|
+
// ─── Branch coverage: router.ts line 235 — aborted loader signal ─────────────
|
|
3253
|
+
|
|
3254
|
+
describe("loader rejection with aborted signal", () => {
|
|
3255
|
+
test("aborted loader rejection is skipped silently", async () => {
|
|
3256
|
+
let errorCallbackCalled = false
|
|
3257
|
+
const loaderRoutes: RouteRecord[] = [
|
|
3258
|
+
{ path: "/", component: Home },
|
|
3259
|
+
{
|
|
3260
|
+
path: "/abort-test",
|
|
3261
|
+
component: About,
|
|
3262
|
+
loader: async ({ signal }) => {
|
|
3263
|
+
// Simulate a fetch that rejects because of abort
|
|
3264
|
+
return new Promise((_, reject) => {
|
|
3265
|
+
signal.addEventListener("abort", () => {
|
|
3266
|
+
reject(new Error("aborted"))
|
|
3267
|
+
})
|
|
3268
|
+
})
|
|
3269
|
+
},
|
|
3270
|
+
},
|
|
3271
|
+
{ path: "/other", component: User, loader: async () => "other-data" },
|
|
3272
|
+
]
|
|
3273
|
+
const router = createRouter({
|
|
3274
|
+
routes: loaderRoutes,
|
|
3275
|
+
url: "/",
|
|
3276
|
+
onError: () => {
|
|
3277
|
+
errorCallbackCalled = true
|
|
3278
|
+
return undefined
|
|
3279
|
+
},
|
|
3280
|
+
})
|
|
3281
|
+
|
|
3282
|
+
// Start navigation to /abort-test (loader will hang until aborted)
|
|
3283
|
+
const nav1 = router.push("/abort-test")
|
|
3284
|
+
// Immediately start another navigation — this aborts the first loader
|
|
3285
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
3286
|
+
await router.push("/other")
|
|
3287
|
+
await nav1
|
|
3288
|
+
|
|
3289
|
+
// The error callback should NOT have been called because the signal was aborted
|
|
3290
|
+
// and the `if (ac.signal.aborted) continue` branch handles it
|
|
3291
|
+
expect(errorCallbackCalled).toBe(false)
|
|
3292
|
+
expect(router.currentRoute().path).toBe("/other")
|
|
3293
|
+
})
|
|
3294
|
+
})
|