@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +510 -115
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +512 -112
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +119 -6
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components.tsx +2 -1
- package/src/index.ts +12 -1
- package/src/match.ts +490 -87
- package/src/router.ts +336 -25
- package/src/tests/router.test.ts +629 -2
- package/src/types.ts +79 -7
package/src/tests/router.test.ts
CHANGED
|
@@ -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 (
|
|
1464
|
+
router.replace = async (location: string | { name: string }) => {
|
|
1461
1465
|
replaceCalled = true
|
|
1462
|
-
return origReplace(
|
|
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
|
+
})
|