@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.
@@ -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
+ })