502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
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: "<div>page</div>" });
|
|
|
|
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<void>((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();
|
|
});
|
|
});
|