import { createApp, defineComponent, nextTick } from "vue"; import { createMemoryHistory, createRouter } from "vue-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createNativeRouter, definePresentation, shouldCommitGesture, springTimeScaleForVelocity, } from "./runtime"; import { createNativeNavigationProfiler } from "./profiler"; const Page = defineComponent({ template: "
page
" }); async function harness(blockB: boolean | "redirect" = false, maxInactive = 2) { const router = createRouter({ history: createMemoryHistory(), routes: [ { path: "/a", component: Page }, { path: "/b", component: Page, meta: { native: { parent: "/a" } } }, { path: "/c", component: Page, meta: { native: { parent: "/a" } } }, { path: "/modal", component: Page, meta: { native: { presentation: "sheet", parent: "/a" } }, }, { path: "/left", component: Page, meta: { native: { siblingOrder: 0, siblingHistory: "replace" } }, }, { path: "/middle", component: Page, meta: { native: { siblingOrder: 1, siblingHistory: "replace" } }, }, { path: "/right", component: Page, meta: { native: { siblingOrder: 2, siblingHistory: "replace" } }, }, { path: "/no-cache", component: Page, meta: { native: { cache: false, parent: "/a" } }, }, { path: "/pinned", component: Page, meta: { native: { cache: "pin", parent: "/a" } }, }, { path: "/item/:id", component: Page, meta: { native: { parent: "/a" } }, }, ], }); if (blockB) router.beforeEach((to) => to.path === "/b" ? (blockB === "redirect" ? "/modal" : false) : undefined, ); await router.push("/a"); await router.isReady(); const native = createNativeRouter({ router, cache: { maxInactive } }); const app = createApp(Page); app.use(router); app.use(native); await nextTick(); return { router, native }; } beforeEach(() => { vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true, } as MediaQueryList); }); describe("gesture decisions", () => { it("uses progress or a deliberate velocity to commit", () => { expect(shouldCommitGesture(0.4, 0)).toBe(true); expect(shouldCommitGesture(0.12, 1.4)).toBe(true); expect(shouldCommitGesture(0.04, 4)).toBe(false); expect(shouldCommitGesture(0.2, 0.4)).toBe(false); }); it("settles a fast flick more quickly without unbounded spring steps", () => { expect(springTimeScaleForVelocity(0)).toBe(1); expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6); expect(springTimeScaleForVelocity(8)).toBe(3); expect(springTimeScaleForVelocity(-20)).toBe(3); }); }); describe("native router transactions", () => { it("exports opt-in frame diagnostics without route params or query values", async () => { const { native } = await harness(); const profiler = createNativeNavigationProfiler(native, { metadata: { build: "test" }, }); profiler.start(); await native.push("/item/private-id?token=secret"); const report = profiler.stop(); expect(report.schema).toBe("native-vue-router-profile@1"); expect(report.metadata).toEqual({ build: "test" }); expect(report.events.map((event) => event.type)).toEqual( expect.arrayContaining([ "route-load-start", "route-load-end", "transaction-start", "transaction-end", ]), ); expect(report.transactions).toMatchObject([ { route: "/item/:id", cold: true, outcome: "committed" }, ]); expect(profiler.toJSON(report)).not.toContain("private-id"); expect(profiler.toJSON(report)).not.toContain("secret"); profiler.dispose(); }); it("preloads a target without changing URL history", async () => { const { router, native } = await harness(); const id = await native.beginInteractive("push", "/b"); expect(id).not.toBeNull(); expect(router.currentRoute.value.path).toBe("/a"); expect( native.entries.value.some((entry) => entry.status === "preview"), ).toBe(true); await native.cancelInteractive(); expect(router.currentRoute.value.path).toBe("/a"); expect(native.transaction.value).toBeNull(); }); it("commits a loaded preview through Vue Router", async () => { const { router, native } = await harness(); await native.beginInteractive("push", "/b"); native.updateInteractive(0.55, 0.1); expect(await native.finishInteractive()).toBe(true); expect(router.currentRoute.value.path).toBe("/b"); expect( native.entries.value.filter((entry) => entry.status === "active"), ).toHaveLength(1); }); it("snaps back and removes the preview when a guard rejects commit", async () => { const { router, native } = await harness(true); await native.beginInteractive("push", "/b"); native.updateInteractive(0.8, 0); expect(await native.finishInteractive()).toBe(false); expect(router.currentRoute.value.path).toBe("/a"); expect( native.entries.value.some((entry) => entry.route.path === "/b"), ).toBe(false); }); it("uses declared parents for cold-start predictive back", async () => { const { router, native } = await harness(); await native.replace("/b", { presentation: "none" }); const transaction = await native.beginInteractive("pop"); expect(transaction).not.toBeNull(); expect(native.transaction.value?.direction).toBe("back"); await native.cancelInteractive(); expect(router.currentRoute.value.path).toBe("/b"); }); it("discards the stale preview when Vue Router redirects a commit", async () => { const { router, native } = await harness("redirect"); await native.beginInteractive("push", "/b"); expect(await native.finishInteractive(true)).toBe(true); expect(router.currentRoute.value.path).toBe("/modal"); expect( native.entries.value.some((entry) => entry.status === "preview"), ).toBe(false); }); it("registers application-defined presentations", async () => { const { native } = await harness(); const presentation = definePresentation({ name: "flip", axis: "x", layerStyle: () => ({ opacity: 0.5 }), }); native.registerPresentation(presentation); expect(native.presentationFor("flip")).toBe(presentation); }); it("treats navigation to the active route as a strict no-op", async () => { const { router, native } = await harness(); const entries = [...native.entries.value]; expect(await native.push("/a")).toBe(false); expect(await native.replace("/a")).toBe(false); expect(router.currentRoute.value.path).toBe("/a"); expect(native.transaction.value).toBeNull(); expect(native.entries.value).toEqual(entries); }); it("derives sibling direction from route order and uses adjacent-page motion", async () => { const { native } = await harness(); await native.replace("/middle", { presentation: "none" }); await native.beginInteractive("sibling", "/left", { replace: true }); expect(native.transaction.value).toMatchObject({ direction: "back", presentation: "slide", }); await native.cancelInteractive(); await native.beginInteractive("sibling", "/right", { replace: true }); expect(native.transaction.value).toMatchObject({ direction: "forward", presentation: "slide", }); await native.cancelInteractive(); }); it("keeps replaced sibling views cached but out of the back stack", async () => { const { native } = await harness(); await native.replace("/left", { presentation: "none" }); await native.sibling("/middle", { replace: true }); await native.push("/c"); await native.beginInteractive("pop"); const target = native.entries.value.find( (entry) => entry.key === native.transaction.value?.toKey, ); expect(target?.route.path).toBe("/middle"); expect(target?.route.path).not.toBe("/left"); await native.cancelInteractive(); }); it("creates sibling views lazily and retains visited replace-style siblings", async () => { const { native } = await harness(); expect(native.entries.value.map((entry) => entry.route.path)).toEqual([ "/a", ]); await native.replace("/left", { presentation: "none" }); expect( native.entries.value.some((entry) => entry.route.path === "/middle"), ).toBe(false); await native.sibling("/middle", { replace: true }); expect( native.entries.value.find((entry) => entry.route.path === "/left"), ).toMatchObject({ mounted: true, status: "inactive" }); expect( native.entries.value.find((entry) => entry.route.path === "/middle"), ).toMatchObject({ mounted: true, status: "active" }); expect( native.entries.value.some((entry) => entry.route.path === "/right"), ).toBe(false); }); it("prepares a newly mounted destination for a paint before animation can begin", async () => { vi.mocked(window.matchMedia).mockReturnValue({ matches: false, } as MediaQueryList); let paint: FrameRequestCallback | undefined; vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { paint = callback; return 1; }); const { native } = await harness(); let resolved = false; const beginning = native.beginInteractive("push", "/b").then((id) => { resolved = true; return id; }); await vi.waitFor(() => expect(paint).toBeTypeOf("function")); expect(resolved).toBe(false); expect( native.entries.value.find((entry) => entry.route.path === "/b")?.mounted, ).toBe(true); paint?.(performance.now()); expect(await beginning).not.toBeNull(); vi.mocked(window.matchMedia).mockReturnValue({ matches: true, } as MediaQueryList); await native.cancelInteractive(); }); it("collapses pushed history when a tab replaces it with an existing root", async () => { const { router, native } = await harness(); await native.push("/b"); expect(native.canGoBack.value).toBe(true); await native.sibling("/a", { replace: true }); expect(router.currentRoute.value.path).toBe("/a"); expect(native.canGoBack.value).toBe(false); expect(await native.beginInteractive("pop")).toBeNull(); }); it("manually unloads inactive route instances but never the active view", async () => { const { native } = await harness(); await native.replace("/left", { presentation: "none" }); await native.sibling("/middle", { replace: true }); expect(native.unload("/left")).toBe(1); expect( native.entries.value.find((entry) => entry.route.path === "/left"), ).toMatchObject({ mounted: false, evictionReason: "manual", }); expect(native.unload("/middle")).toBe(0); expect( native.entries.value.find((entry) => entry.route.path === "/middle") ?.mounted, ).toBe(true); }); it("evicts the least-recently-used inactive view when the cache limit is exceeded", async () => { let clock = 0; vi.spyOn(performance, "now").mockImplementation(() => ++clock); const { native } = await harness(); await native.replace("/left", { presentation: "none" }); await native.sibling("/middle", { replace: true }); await native.sibling("/right", { replace: true }); await native.sibling("/a", { replace: true }); expect( native.entries.value.find((entry) => entry.route.path === "/left"), ).toMatchObject({ mounted: false, evictionReason: "cache-limit", }); expect( native.entries.value.find((entry) => entry.route.path === "/middle") ?.mounted, ).toBe(true); expect( native.entries.value.find((entry) => entry.route.path === "/right") ?.mounted, ).toBe(true); expect(native.cacheStats.value.inactive).toBe(2); }); it("evicts a pushed route after it is popped out of history", async () => { const { native } = await harness(); await native.push("/b"); const pushedKey = native.activeKey.value; await native.pop(); expect( native.entries.value.find((entry) => entry.key === pushedKey), ).toMatchObject({ mounted: false, status: "evicted", evictionReason: "popped", }); }); it("honors cache opt-out even for a route that remains in back history", async () => { const { native } = await harness(); await native.push("/no-cache"); const noCacheKey = native.activeKey.value; await native.push("/c"); expect( native.entries.value.find((entry) => entry.key === noCacheKey), ).toMatchObject({ mounted: false, evictionReason: "cache-disabled", }); expect(await native.beginInteractive("pop")).not.toBeNull(); expect( native.entries.value.find((entry) => entry.key === noCacheKey)?.mounted, ).toBe(true); await native.cancelInteractive(); }); it("keeps pinned views during normal trims and releases them when requested", async () => { const { native } = await harness(); await native.replace("/pinned", { presentation: "none" }); const pinnedKey = native.activeKey.value; await native.push("/c"); native.trimCache(); expect( native.entries.value.find((entry) => entry.key === pinnedKey)?.mounted, ).toBe(true); native.trimCache({ includePinned: true }); expect( native.entries.value.find((entry) => entry.key === pinnedKey), ).toMatchObject({ mounted: false, evictionReason: "trimmed", }); }); it("evicts a previously cached target when its guard rejects re-entry", async () => { const { router, native } = await harness(); await native.replace("/left", { presentation: "none" }); await native.sibling("/middle", { replace: true }); const cachedLeft = native.entries.value.find( (entry) => entry.route.path === "/left", ); expect(cachedLeft?.mounted).toBe(true); const removeGuard = router.beforeEach((to) => to.path === "/left" ? false : undefined, ); expect(await native.sibling("/left", { replace: true })).toBe(false); expect( native.entries.value.find((entry) => entry.key === cachedLeft?.key), ).toMatchObject({ mounted: false, evictionReason: "navigation-rejected", }); expect(native.cacheStats.value.totalEvictions).toBeGreaterThan(0); removeGuard(); }); it("does not preview a stale forward entry after pop then push", async () => { const { native } = await harness(); await native.push("/b"); await native.pop(); await native.push("/c"); await native.beginInteractive("pop"); const target = native.entries.value.find( (entry) => entry.key === native.transaction.value?.toKey, ); expect(target?.route.path).toBe("/a"); await native.cancelInteractive(); }); it("dismisses with the presented route animation regardless of the route below it", async () => { const { native } = await harness(); await native.push("/b"); const underlayKey = native.activeKey.value; await native.present("/modal", "sheet"); expect( native.entries.value.find( (entry) => entry.key === native.activeKey.value, ), ).toMatchObject({ presentation: "sheet", underlayKey }); await native.beginInteractive("dismiss"); expect(native.transaction.value).toMatchObject({ direction: "back", presentation: "sheet", }); await native.cancelInteractive(); }); it("protects a visible sheet underlay even when inactive caching is disabled", async () => { const { native } = await harness(false, 0); await native.push("/b"); const underlayKey = native.activeKey.value; await native.present("/modal", "sheet"); expect( native.entries.value.find((entry) => entry.key === underlayKey), ).toMatchObject({ mounted: true, status: "inactive" }); await native.dismiss(); }); it("refuses to overlap a second transaction with an active gesture", async () => { const { native } = await harness(); const first = await native.beginInteractive("push", "/b"); expect(await native.beginInteractive("push", "/c")).toBeNull(); expect(native.transaction.value?.id).toBe(first); await native.cancelInteractive(); }); it("accepts imperative navigation as the previous transition finalizes", async () => { const { router, native } = await harness(); await native.beginInteractive("push", "/b"); const finishing = native.finishInteractive(true); const queued = native.push("/c"); expect(await finishing).toBe(true); expect(await queued).toBe(true); expect(router.currentRoute.value.path).toBe("/c"); expect(native.transaction.value).toBeNull(); }); it("reconciles direct browser back navigation with the native stack", async () => { const { router, native } = await harness(); await native.push("/b"); const navigated = new Promise((resolve) => { const remove = router.afterEach(() => { remove(); resolve(); }); }); router.back(); await navigated; expect(router.currentRoute.value.path).toBe("/a"); expect(native.canGoBack.value).toBe(false); expect(await native.beginInteractive("pop")).toBeNull(); }); });