@pyreon/router 0.3.0 → 0.4.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.
@@ -5,14 +5,18 @@ import {
5
5
  createRouter,
6
6
  hydrateLoaderData,
7
7
  lazy,
8
+ onBeforeRouteLeave,
9
+ onBeforeRouteUpdate,
8
10
  prefetchLoaderData,
9
11
  RouterLink,
10
12
  RouterProvider,
11
13
  RouterView,
12
14
  serializeLoaderData,
15
+ useBlocker,
13
16
  useLoaderData,
14
17
  useRoute,
15
18
  useRouter,
19
+ useSearchParams,
16
20
  } from "../index"
17
21
  import {
18
22
  buildNameIndex,
@@ -1457,9 +1461,9 @@ describe("RouterLink", () => {
1457
1461
  const router = createRouter({ routes, url: "/" })
1458
1462
  let replaceCalled = false
1459
1463
  const origReplace = router.replace.bind(router)
1460
- router.replace = async (path: string) => {
1464
+ router.replace = async (location: string | { name: string }) => {
1461
1465
  replaceCalled = true
1462
- return origReplace(path)
1466
+ return origReplace(location as string)
1463
1467
  }
1464
1468
  mount(
1465
1469
  h(RouterProvider, { router }, h(RouterLink, { to: "/about", replace: true }, "About")),
@@ -3292,3 +3296,626 @@ describe("loader rejection with aborted signal", () => {
3292
3296
  expect(router.currentRoute().path).toBe("/other")
3293
3297
  })
3294
3298
  })
3299
+
3300
+ // ─── Feature 1: Base path support ─────────────────────────────────────────────
3301
+
3302
+ describe("base path", () => {
3303
+ const baseRoutes: RouteRecord[] = [
3304
+ { path: "/", component: Home },
3305
+ { path: "/about", component: About },
3306
+ { path: "/user/:id", component: User },
3307
+ ]
3308
+
3309
+ it("strips base from SSR url", () => {
3310
+ const router = createRouter({
3311
+ routes: baseRoutes,
3312
+ mode: "history",
3313
+ base: "/app",
3314
+ url: "/app/about",
3315
+ })
3316
+ expect(router.currentRoute().path).toBe("/about")
3317
+ })
3318
+
3319
+ it("resolves root when SSR url equals the base", () => {
3320
+ const router = createRouter({ routes: baseRoutes, mode: "history", base: "/app", url: "/app" })
3321
+ expect(router.currentRoute().path).toBe("/")
3322
+ })
3323
+
3324
+ it("resolves root when SSR url is base with trailing slash", () => {
3325
+ const router = createRouter({ routes: baseRoutes, mode: "history", base: "/app", url: "/app/" })
3326
+ expect(router.currentRoute().path).toBe("/")
3327
+ })
3328
+
3329
+ it("normalizes base without leading slash", () => {
3330
+ const router = createRouter({
3331
+ routes: baseRoutes,
3332
+ mode: "history",
3333
+ base: "app",
3334
+ url: "/app/about",
3335
+ })
3336
+ expect(router.currentRoute().path).toBe("/about")
3337
+ })
3338
+
3339
+ it("strips trailing slash from base", () => {
3340
+ const router = createRouter({
3341
+ routes: baseRoutes,
3342
+ mode: "history",
3343
+ base: "/app/",
3344
+ url: "/app/about",
3345
+ })
3346
+ expect(router.currentRoute().path).toBe("/about")
3347
+ })
3348
+
3349
+ it("ignores base in hash mode", () => {
3350
+ const router = createRouter({ routes: baseRoutes, mode: "hash", base: "/app", url: "/about" })
3351
+ expect(router.currentRoute().path).toBe("/about")
3352
+ })
3353
+
3354
+ it("stores normalized base on RouterInstance", () => {
3355
+ const router = createRouter({
3356
+ routes: baseRoutes,
3357
+ mode: "history",
3358
+ base: "/app/",
3359
+ }) as unknown as RouterInstance
3360
+ expect(router._base).toBe("/app")
3361
+ })
3362
+
3363
+ it("prepends base to browser URL in syncBrowserUrl via push", async () => {
3364
+ const router = createRouter({
3365
+ routes: baseRoutes,
3366
+ mode: "history",
3367
+ base: "/app",
3368
+ url: "/app/",
3369
+ })
3370
+ await router.push("/about")
3371
+ expect(router.currentRoute().path).toBe("/about")
3372
+ expect(router.currentRoute().matched.length).toBeGreaterThan(0)
3373
+ router.destroy()
3374
+ })
3375
+ })
3376
+
3377
+ // ─── Feature 2: Navigation blockers ──────────────────────────────────────────
3378
+
3379
+ describe("navigation blockers (useBlocker)", () => {
3380
+ const blockerRoutes: RouteRecord[] = [
3381
+ { path: "/", component: Home },
3382
+ { path: "/about", component: About },
3383
+ { path: "/user/:id", component: User },
3384
+ ]
3385
+
3386
+ beforeEach(() => {
3387
+ // Reset browser state so stale hash values from prior tests don't trigger hashchange
3388
+ window.location.hash = ""
3389
+ })
3390
+
3391
+ afterEach(() => {
3392
+ setActiveRouter(null)
3393
+ })
3394
+
3395
+ it("blocks navigation when blocker returns true", async () => {
3396
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3397
+ router._blockers.add(() => true)
3398
+ await router.push("/about")
3399
+ expect(router.currentRoute().path).toBe("/")
3400
+ router.destroy()
3401
+ })
3402
+
3403
+ it("allows navigation when blocker returns false", async () => {
3404
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3405
+ router._blockers.add(() => false)
3406
+ await router.push("/about")
3407
+ expect(router.currentRoute().path).toBe("/about")
3408
+ router.destroy()
3409
+ })
3410
+
3411
+ it("supports async blockers", async () => {
3412
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3413
+ router._blockers.add(async () => {
3414
+ await new Promise<void>((r) => setTimeout(r, 5))
3415
+ return true
3416
+ })
3417
+ await router.push("/about")
3418
+ expect(router.currentRoute().path).toBe("/")
3419
+ router.destroy()
3420
+ })
3421
+
3422
+ it("removing blocker allows navigation", async () => {
3423
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3424
+ const fn = () => true
3425
+ router._blockers.add(fn)
3426
+ router._blockers.delete(fn)
3427
+ await router.push("/about")
3428
+ expect(router.currentRoute().path).toBe("/about")
3429
+ router.destroy()
3430
+ })
3431
+
3432
+ it("blocker receives to and from routes", async () => {
3433
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3434
+ let receivedTo: ResolvedRoute | undefined
3435
+ let receivedFrom: ResolvedRoute | undefined
3436
+ router._blockers.add((to, from) => {
3437
+ receivedTo = to
3438
+ receivedFrom = from
3439
+ return false
3440
+ })
3441
+ await router.push("/about")
3442
+ expect(receivedTo!.path).toBe("/about")
3443
+ expect(receivedFrom!.path).toBe("/")
3444
+ router.destroy()
3445
+ })
3446
+
3447
+ it("destroy() clears blockers", () => {
3448
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3449
+ router._blockers.add(() => true)
3450
+ router.destroy()
3451
+ expect(router._blockers.size).toBe(0)
3452
+ })
3453
+
3454
+ it("useBlocker hook works via RouterProvider", () => {
3455
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3456
+ let blockerRef: { remove(): void } | undefined
3457
+ const container = document.createElement("div")
3458
+ const TestComp = () => {
3459
+ blockerRef = useBlocker(() => true)
3460
+ return null
3461
+ }
3462
+ mount(h(RouterProvider, { router }, h(TestComp, {})), container)
3463
+ expect(router._blockers.size).toBe(1)
3464
+ blockerRef!.remove()
3465
+ expect(router._blockers.size).toBe(0)
3466
+ router.destroy()
3467
+ })
3468
+
3469
+ it("auto-removes blocker on component unmount via destroy()", () => {
3470
+ const router = createRouter({ routes: blockerRoutes, url: "/" }) as unknown as RouterInstance
3471
+ const container = document.createElement("div")
3472
+ const TestComp = () => {
3473
+ useBlocker(() => true)
3474
+ return null
3475
+ }
3476
+ mount(h(RouterProvider, { router }, h(TestComp, {})), container)
3477
+ expect(router._blockers.size).toBe(1)
3478
+ // destroy() triggers onUnmount callbacks which should auto-remove the blocker
3479
+ router.destroy()
3480
+ expect(router._blockers.size).toBe(0)
3481
+ })
3482
+ })
3483
+
3484
+ // ─── Feature 3: Relative navigation ──────────────────────────────────────────
3485
+
3486
+ describe("relative navigation", () => {
3487
+ const relRoutes: RouteRecord[] = [
3488
+ { path: "/", component: Home },
3489
+ { path: "/admin/users", component: AdminUsers },
3490
+ { path: "/admin/settings", component: AdminSettings },
3491
+ { path: "/about", component: About },
3492
+ ]
3493
+
3494
+ it("resolves ./sibling from a nested path", async () => {
3495
+ const router = createRouter({ routes: relRoutes, url: "/admin/users" })
3496
+ await router.push("./settings")
3497
+ expect(router.currentRoute().path).toBe("/admin/settings")
3498
+ })
3499
+
3500
+ it("resolves ../up from a nested path", async () => {
3501
+ const router = createRouter({ routes: relRoutes, url: "/admin/users" })
3502
+ await router.push("../about")
3503
+ expect(router.currentRoute().path).toBe("/about")
3504
+ })
3505
+
3506
+ it("resolves . to the parent directory", async () => {
3507
+ const router = createRouter({ routes: relRoutes, url: "/admin/users" })
3508
+ await router.push(".")
3509
+ expect(router.currentRoute().path).toBe("/admin")
3510
+ })
3511
+
3512
+ it("resolves .. to the grandparent directory", async () => {
3513
+ const router = createRouter({ routes: relRoutes, url: "/admin/users" })
3514
+ await router.push("..")
3515
+ expect(router.currentRoute().path).toBe("/")
3516
+ })
3517
+
3518
+ it("does not modify absolute paths", async () => {
3519
+ const router = createRouter({ routes: relRoutes, url: "/admin/users" })
3520
+ await router.push("/about")
3521
+ expect(router.currentRoute().path).toBe("/about")
3522
+ })
3523
+
3524
+ it("works with replace()", async () => {
3525
+ const router = createRouter({ routes: relRoutes, url: "/admin/users" })
3526
+ await router.replace("./settings")
3527
+ expect(router.currentRoute().path).toBe("/admin/settings")
3528
+ })
3529
+ })
3530
+
3531
+ // ─── Feature 4: Trailing slash normalization ──────────────────────────────────
3532
+
3533
+ describe("trailing slash normalization", () => {
3534
+ const tsRoutes: RouteRecord[] = [
3535
+ { path: "/", component: Home },
3536
+ { path: "/about", component: About },
3537
+ { path: "/user/:id", component: User },
3538
+ ]
3539
+
3540
+ it("strips trailing slash by default", () => {
3541
+ const router = createRouter({ routes: tsRoutes, url: "/about/" })
3542
+ expect(router.currentRoute().path).toBe("/about")
3543
+ })
3544
+
3545
+ it("strips trailing slash on navigation", async () => {
3546
+ const router = createRouter({ routes: tsRoutes, url: "/" })
3547
+ await router.push("/about/")
3548
+ expect(router.currentRoute().path).toBe("/about")
3549
+ })
3550
+
3551
+ it("adds trailing slash when trailingSlash=add", () => {
3552
+ const router = createRouter({ routes: tsRoutes, url: "/about", trailingSlash: "add" })
3553
+ expect(router.currentRoute().path).toBe("/about/")
3554
+ })
3555
+
3556
+ it("does not modify when trailingSlash=ignore", () => {
3557
+ const router = createRouter({ routes: tsRoutes, url: "/about/", trailingSlash: "ignore" })
3558
+ expect(router.currentRoute().path).toBe("/about/")
3559
+ })
3560
+
3561
+ it("does not strip slash from root path", () => {
3562
+ const router = createRouter({ routes: tsRoutes, url: "/" })
3563
+ expect(router.currentRoute().path).toBe("/")
3564
+ })
3565
+
3566
+ it("preserves query string when stripping slash", async () => {
3567
+ const router = createRouter({ routes: tsRoutes, url: "/" })
3568
+ await router.push("/about/?foo=bar")
3569
+ const route = router.currentRoute()
3570
+ expect(route.path).toBe("/about")
3571
+ expect(route.query.foo).toBe("bar")
3572
+ })
3573
+ })
3574
+
3575
+ // ─── Feature 5: Typed search params ──────────────────────────────────────────
3576
+
3577
+ describe("useSearchParams", () => {
3578
+ const spRoutes: RouteRecord[] = [
3579
+ { path: "/", component: Home },
3580
+ { path: "/search", component: About },
3581
+ ]
3582
+
3583
+ it("returns current query params via RouterProvider", () => {
3584
+ const router = createRouter({ routes: spRoutes, url: "/search?q=hello&page=2" })
3585
+ const container = document.createElement("div")
3586
+ let result: Record<string, string> | undefined
3587
+ const TestComp = () => {
3588
+ const [params] = useSearchParams()
3589
+ result = params()
3590
+ return null
3591
+ }
3592
+ mount(h(RouterProvider, { router }, h(TestComp, {})), container)
3593
+ expect(result!.q).toBe("hello")
3594
+ expect(result!.page).toBe("2")
3595
+ router.destroy()
3596
+ })
3597
+
3598
+ it("merges URL params over defaults for missing keys", () => {
3599
+ const router = createRouter({ routes: spRoutes, url: "/search?q=hello" })
3600
+ const container = document.createElement("div")
3601
+ let result: Record<string, string> | undefined
3602
+ const TestComp = () => {
3603
+ const [params] = useSearchParams({ q: "", page: "1", sort: "name" })
3604
+ result = params()
3605
+ return null
3606
+ }
3607
+ mount(h(RouterProvider, { router }, h(TestComp, {})), container)
3608
+ expect(result!.q).toBe("hello")
3609
+ expect(result!.page).toBe("1")
3610
+ expect(result!.sort).toBe("name")
3611
+ router.destroy()
3612
+ })
3613
+
3614
+ it("URL params take precedence over defaults", () => {
3615
+ const router = createRouter({ routes: spRoutes, url: "/search?page=5" })
3616
+ const container = document.createElement("div")
3617
+ let result: Record<string, string> | undefined
3618
+ const TestComp = () => {
3619
+ const [params] = useSearchParams({ page: "1" })
3620
+ result = params()
3621
+ return null
3622
+ }
3623
+ mount(h(RouterProvider, { router }, h(TestComp, {})), container)
3624
+ expect(result!.page).toBe("5")
3625
+ router.destroy()
3626
+ })
3627
+
3628
+ it("setSearchParams updates query and navigates", async () => {
3629
+ const router = createRouter({ routes: spRoutes, url: "/search?page=1" })
3630
+ const container = document.createElement("div")
3631
+ let setter: ((updates: Partial<Record<string, string>>) => Promise<void>) | undefined
3632
+ const TestComp = () => {
3633
+ const [, setParams] = useSearchParams({ page: "1", sort: "name" })
3634
+ setter = setParams
3635
+ return null
3636
+ }
3637
+ mount(h(RouterProvider, { router }, h(TestComp, {})), container)
3638
+ await setter!({ page: "3" })
3639
+ expect(router.currentRoute().query.page).toBe("3")
3640
+ expect(router.currentRoute().query.sort).toBe("name")
3641
+ router.destroy()
3642
+ })
3643
+ })
3644
+
3645
+ // ─── Feature 6: Stale-while-revalidate loaders ───────────────────────────────
3646
+
3647
+ describe("stale-while-revalidate loaders", () => {
3648
+ const Comp = () => null
3649
+
3650
+ it("uses cached data and revalidates in background", async () => {
3651
+ let callCount = 0
3652
+ const swrRoutes: RouteRecord[] = [
3653
+ { path: "/", component: Comp },
3654
+ {
3655
+ path: "/data",
3656
+ component: Comp,
3657
+ staleWhileRevalidate: true,
3658
+ loader: async () => {
3659
+ callCount++
3660
+ return `result-${callCount}`
3661
+ },
3662
+ },
3663
+ ]
3664
+
3665
+ const router = createRouter({ routes: swrRoutes, url: "/" })
3666
+
3667
+ // First navigation: no cached data, loader runs blocking
3668
+ await router.push("/data")
3669
+ expect(callCount).toBe(1)
3670
+ const inst = router as unknown as RouterInstance
3671
+ const record = swrRoutes[1] as RouteRecord
3672
+ expect(inst._loaderData.get(record)).toBe("result-1")
3673
+
3674
+ // Navigate away then back — SWR should use cached data
3675
+ await router.push("/")
3676
+ await router.push("/data")
3677
+ // callCount is now 2 because loader was called again (SWR revalidation)
3678
+ // But navigation committed immediately with stale data
3679
+ await new Promise<void>((r) => setTimeout(r, 50))
3680
+ expect(callCount).toBe(2)
3681
+ expect(inst._loaderData.get(record)).toBe("result-2")
3682
+ })
3683
+
3684
+ it("non-SWR routes still block", async () => {
3685
+ let loaded = false
3686
+ const mixedRoutes: RouteRecord[] = [
3687
+ { path: "/", component: Comp },
3688
+ {
3689
+ path: "/blocking",
3690
+ component: Comp,
3691
+ loader: async () => {
3692
+ await new Promise<void>((r) => setTimeout(r, 10))
3693
+ loaded = true
3694
+ return "data"
3695
+ },
3696
+ },
3697
+ ]
3698
+
3699
+ const router = createRouter({ routes: mixedRoutes, url: "/" })
3700
+ await router.push("/blocking")
3701
+ expect(loaded).toBe(true)
3702
+ })
3703
+ })
3704
+
3705
+ // ─── go(n), forward() ────────────────────────────────────────────────────────
3706
+
3707
+ describe("go() and forward()", () => {
3708
+ test("go() calls window.history.go", () => {
3709
+ const spy = vi.spyOn(window.history, "go").mockImplementation(() => {})
3710
+ const router = createRouter({ routes, url: "/" })
3711
+ router.go(-2)
3712
+ expect(spy).toHaveBeenCalledWith(-2)
3713
+ router.go(3)
3714
+ expect(spy).toHaveBeenCalledWith(3)
3715
+ spy.mockRestore()
3716
+ router.destroy()
3717
+ })
3718
+
3719
+ test("forward() calls window.history.forward", () => {
3720
+ const spy = vi.spyOn(window.history, "forward").mockImplementation(() => {})
3721
+ const router = createRouter({ routes, url: "/" })
3722
+ router.forward()
3723
+ expect(spy).toHaveBeenCalled()
3724
+ spy.mockRestore()
3725
+ router.destroy()
3726
+ })
3727
+ })
3728
+
3729
+ // ─── Named replace() ──────────────────────────────────────────────────────────
3730
+
3731
+ describe("named replace()", () => {
3732
+ const namedRoutes: RouteRecord[] = [
3733
+ { path: "/", component: Home },
3734
+ { path: "/user/:id", component: User, name: "user" },
3735
+ { path: "/about", component: About, name: "about" },
3736
+ ]
3737
+
3738
+ test("replace with named route navigates correctly", async () => {
3739
+ const router = createRouter({ routes: namedRoutes, url: "/" })
3740
+ await router.replace({ name: "user", params: { id: "42" } })
3741
+ expect(router.currentRoute().path).toBe("/user/42")
3742
+ router.destroy()
3743
+ })
3744
+
3745
+ test("replace with named route and query", async () => {
3746
+ const router = createRouter({ routes: namedRoutes, url: "/" })
3747
+ await router.replace({ name: "about", query: { ref: "home" } })
3748
+ expect(router.currentRoute().path).toBe("/about")
3749
+ expect(router.currentRoute().query.ref).toBe("home")
3750
+ router.destroy()
3751
+ })
3752
+ })
3753
+
3754
+ // ─── Optional params ──────────────────────────────────────────────────────────
3755
+
3756
+ describe("optional params", () => {
3757
+ const optRoutes: RouteRecord[] = [
3758
+ { path: "/", component: Home },
3759
+ { path: "/lang/:locale?/about", component: About },
3760
+ { path: "/user/:id/:tab?", component: User },
3761
+ ]
3762
+
3763
+ test("matches with optional param present", () => {
3764
+ const r = resolveOn(optRoutes, "/user/42/settings") as {
3765
+ params: Record<string, string>
3766
+ }
3767
+ expect(r.params.id).toBe("42")
3768
+ expect(r.params.tab).toBe("settings")
3769
+ })
3770
+
3771
+ test("matches with optional param absent", () => {
3772
+ const r = resolveOn(optRoutes, "/user/42") as {
3773
+ params: Record<string, string>
3774
+ matched: unknown[]
3775
+ }
3776
+ expect(r.params.id).toBe("42")
3777
+ expect(r.params.tab).toBeUndefined()
3778
+ expect(r.matched.length).toBeGreaterThan(0)
3779
+ })
3780
+
3781
+ test("matches mid-path optional param present", () => {
3782
+ const r = resolveOn(optRoutes, "/lang/en/about") as {
3783
+ params: Record<string, string>
3784
+ }
3785
+ expect(r.params.locale).toBe("en")
3786
+ })
3787
+
3788
+ test("matchPath supports optional params", () => {
3789
+ expect(matchPath("/user/:id/:tab?", "/user/42")).toEqual({ id: "42" })
3790
+ expect(matchPath("/user/:id/:tab?", "/user/42/settings")).toEqual({
3791
+ id: "42",
3792
+ tab: "settings",
3793
+ })
3794
+ })
3795
+
3796
+ test("buildPath omits optional segment when param is missing", () => {
3797
+ expect(buildPath("/user/:id/:tab?", { id: "42" })).toBe("/user/42")
3798
+ expect(buildPath("/user/:id/:tab?", { id: "42", tab: "settings" })).toBe("/user/42/settings")
3799
+ })
3800
+ })
3801
+
3802
+ // ─── isReady() ───────────────────────────────────────────────────────────────
3803
+
3804
+ describe("isReady()", () => {
3805
+ test("resolves after router is created", async () => {
3806
+ const router = createRouter({ routes, url: "/" })
3807
+ await router.isReady()
3808
+ expect(router.currentRoute().path).toBe("/")
3809
+ router.destroy()
3810
+ })
3811
+
3812
+ test("calling isReady() multiple times returns the same promise", () => {
3813
+ const router = createRouter({ routes, url: "/" })
3814
+ const p1 = router.isReady()
3815
+ const p2 = router.isReady()
3816
+ expect(p1).toBe(p2)
3817
+ router.destroy()
3818
+ })
3819
+ })
3820
+
3821
+ // ─── Route aliases ───────────────────────────────────────────────────────────
3822
+
3823
+ describe("route aliases", () => {
3824
+ const aliasRoutes: RouteRecord[] = [
3825
+ { path: "/", component: Home },
3826
+ { path: "/user/:id", alias: "/profile/:id", component: User, name: "user" },
3827
+ {
3828
+ path: "/about",
3829
+ alias: ["/about-us", "/info"],
3830
+ component: About,
3831
+ meta: { title: "About" },
3832
+ },
3833
+ ]
3834
+
3835
+ test("alias path matches same component as primary", () => {
3836
+ const r1 = resolveOn(aliasRoutes, "/user/42") as {
3837
+ params: Record<string, string>
3838
+ matched: { component: unknown }[]
3839
+ }
3840
+ const r2 = resolveOn(aliasRoutes, "/profile/42") as {
3841
+ params: Record<string, string>
3842
+ matched: { component: unknown }[]
3843
+ }
3844
+ expect(r1.matched[0]?.component).toBe(User)
3845
+ expect(r2.matched[0]?.component).toBe(User)
3846
+ expect(r2.params.id).toBe("42")
3847
+ })
3848
+
3849
+ test("multiple aliases all match", () => {
3850
+ const r1 = resolveOn(aliasRoutes, "/about") as { matched: { component: unknown }[] }
3851
+ const r2 = resolveOn(aliasRoutes, "/about-us") as { matched: { component: unknown }[] }
3852
+ const r3 = resolveOn(aliasRoutes, "/info") as { matched: { component: unknown }[] }
3853
+ expect(r1.matched[0]?.component).toBe(About)
3854
+ expect(r2.matched[0]?.component).toBe(About)
3855
+ expect(r3.matched[0]?.component).toBe(About)
3856
+ })
3857
+
3858
+ test("alias shares meta with primary route", () => {
3859
+ const r = resolveOn(aliasRoutes, "/about-us") as { meta: { title?: string } }
3860
+ expect(r.meta.title).toBe("About")
3861
+ })
3862
+ })
3863
+
3864
+ // ─── In-component guards ─────────────────────────────────────────────────────
3865
+
3866
+ describe("onBeforeRouteLeave", () => {
3867
+ test("blocks navigation when returning false", async () => {
3868
+ const guardRoutes: RouteRecord[] = [
3869
+ { path: "/", component: Home },
3870
+ { path: "/about", component: About },
3871
+ ]
3872
+ const router = createRouter({ routes: guardRoutes, url: "/" })
3873
+ const el = container()
3874
+ let leaveCalled = false
3875
+
3876
+ const App = () => {
3877
+ onBeforeRouteLeave(() => {
3878
+ leaveCalled = true
3879
+ return false
3880
+ })
3881
+ return h(RouterView, { router })
3882
+ }
3883
+
3884
+ mount(h(RouterProvider, { router }, h(App, {})), el)
3885
+ await router.push("/about")
3886
+ expect(leaveCalled).toBe(true)
3887
+ expect(router.currentRoute().path).toBe("/")
3888
+ router.destroy()
3889
+ })
3890
+ })
3891
+
3892
+ describe("onBeforeRouteUpdate", () => {
3893
+ test("fires when params change on the same route", async () => {
3894
+ const guardRoutes: RouteRecord[] = [
3895
+ { path: "/", component: Home },
3896
+ { path: "/user/:id", component: User },
3897
+ ]
3898
+ const router = createRouter({ routes: guardRoutes, url: "/user/1" })
3899
+ const el = container()
3900
+ let updateCalled = false
3901
+
3902
+ const App = () => {
3903
+ onBeforeRouteUpdate((to) => {
3904
+ updateCalled = true
3905
+ if (to.params.id === "blocked") return false
3906
+ return undefined
3907
+ })
3908
+ return h(RouterView, { router })
3909
+ }
3910
+
3911
+ mount(h(RouterProvider, { router }, h(App, {})), el)
3912
+ await router.push("/user/2")
3913
+ expect(updateCalled).toBe(true)
3914
+ expect(router.currentRoute().path).toBe("/user/2")
3915
+
3916
+ // Should block navigation to /user/blocked
3917
+ await router.push("/user/blocked")
3918
+ expect(router.currentRoute().path).toBe("/user/2")
3919
+ router.destroy()
3920
+ })
3921
+ })