Add prettier. Format.

This commit is contained in:
2026-07-22 02:12:05 +00:00
parent d28b09dc21
commit 18baa96848
45 changed files with 4520 additions and 2613 deletions

View File

@@ -3,11 +3,20 @@
"version": "0.1.0",
"type": "module",
"license": "MIT",
"files": ["dist"],
"sideEffects": ["./dist/style.css"],
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" },
"files": [
"dist"
],
"sideEffects": [
"./dist/style.css"
],
"scripts": {
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
},
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css"
},
"peerDependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
export * from './types'
export * from './runtime'
export * from './components'
export * from './profiler'
import './style.css'
export * from "./types";
export * from "./runtime";
export * from "./components";
export * from "./profiler";
import "./style.css";
declare module '@vue/runtime-core' {
declare module "@vue/runtime-core" {
interface ComponentCustomProperties {
$nativeRouter: import('./types').NativeRouterRuntime
$nativeRouter: import("./types").NativeRouterRuntime;
}
}

View File

@@ -1,111 +1,117 @@
import { readonly, ref } from 'vue'
import type { NativeDiagnosticEvent, NativeRouterRuntime } from './types'
import { readonly, ref } from "vue";
import type { NativeDiagnosticEvent, NativeRouterRuntime } from "./types";
export interface NativeProfilerOptions {
/** Extra non-sensitive identifiers such as an application build ID. */
metadata?: Record<string, string | number | boolean>
metadata?: Record<string, string | number | boolean>;
/** Maximum rAF samples retained. Defaults to 30,000 (about eight minutes at 60 Hz). */
maxFrames?: number
maxFrames?: number;
}
export interface NativeProfilerFrame {
at: number
delta: number
route: string
transactionId?: number
phase?: string
progress?: number
at: number;
delta: number;
route: string;
transactionId?: number;
phase?: string;
progress?: number;
}
export interface NativeProfilerPerformanceEntry {
type: 'longtask' | 'layout-shift' | 'resource' | 'event'
at: number
duration: number
name?: string
value?: number
size?: number
hadRecentInput?: boolean
type: "longtask" | "layout-shift" | "resource" | "event";
at: number;
duration: number;
name?: string;
value?: number;
size?: number;
hadRecentInput?: boolean;
}
export interface NativeProfilerTransactionSummary {
id: number
route?: string
kind?: string
cold?: boolean
outcome?: string
duration?: number
frames: number
slowFrames: number
p95FrameMs?: number
maxFrameMs?: number
id: number;
route?: string;
kind?: string;
cold?: boolean;
outcome?: string;
duration?: number;
frames: number;
slowFrames: number;
p95FrameMs?: number;
maxFrameMs?: number;
}
export interface NativeProfilerReport {
schema: 'native-vue-router-profile@1'
startedAt: string
duration: number
schema: "native-vue-router-profile@1";
startedAt: string;
duration: number;
environment: {
userAgent: string
viewport: { width: number; height: number; devicePixelRatio: number }
displayMode: 'standalone' | 'browser'
visibility: DocumentVisibilityState
hardwareConcurrency?: number
deviceMemory?: number
}
metadata: Record<string, string | number | boolean>
userAgent: string;
viewport: { width: number; height: number; devicePixelRatio: number };
displayMode: "standalone" | "browser";
visibility: DocumentVisibilityState;
hardwareConcurrency?: number;
deviceMemory?: number;
};
metadata: Record<string, string | number | boolean>;
summary: {
frames: number
estimatedRefreshMs: number
estimatedRefreshHz: number
averageFps: number
p95FrameMs: number
maxFrameMs: number
framesOver20ms: number
framesOver34ms: number
framesOver50ms: number
droppedFrames: number
longTasks: number
cumulativeLayoutShift: number
}
transactions: NativeProfilerTransactionSummary[]
events: NativeDiagnosticEvent[]
frames: NativeProfilerFrame[]
performanceEntries: NativeProfilerPerformanceEntry[]
visibility: Array<{ at: number; state: DocumentVisibilityState }>
frames: number;
estimatedRefreshMs: number;
estimatedRefreshHz: number;
averageFps: number;
p95FrameMs: number;
maxFrameMs: number;
framesOver20ms: number;
framesOver34ms: number;
framesOver50ms: number;
droppedFrames: number;
longTasks: number;
cumulativeLayoutShift: number;
};
transactions: NativeProfilerTransactionSummary[];
events: NativeDiagnosticEvent[];
frames: NativeProfilerFrame[];
performanceEntries: NativeProfilerPerformanceEntry[];
visibility: Array<{ at: number; state: DocumentVisibilityState }>;
}
export interface NativeNavigationProfiler {
readonly recording: Readonly<{ value: boolean }>
start(): void
stop(): NativeProfilerReport
snapshot(): NativeProfilerReport
clear(): void
toJSON(report?: NativeProfilerReport): string
dispose(): void
readonly recording: Readonly<{ value: boolean }>;
start(): void;
stop(): NativeProfilerReport;
snapshot(): NativeProfilerReport;
clear(): void;
toJSON(report?: NativeProfilerReport): string;
dispose(): void;
}
function round(value: number, digits = 2) {
const scale = 10 ** digits
return Math.round(value * scale) / scale
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
function percentile(values: number[], position: number) {
if (!values.length) return 0
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * position))] ?? 0
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
return (
sorted[
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * position))
] ?? 0
);
}
function routeLabel(runtime: NativeRouterRuntime) {
const route = runtime.router.currentRoute.value
return route.name != null ? String(route.name) : route.matched.at(-1)?.path ?? route.path
const route = runtime.router.currentRoute.value;
return route.name != null
? String(route.name)
: (route.matched.at(-1)?.path ?? route.path);
}
function resourceName(value: string) {
try {
const url = new URL(value, window.location.href)
return url.pathname.split('/').at(-1) || url.pathname
const url = new URL(value, window.location.href);
return url.pathname.split("/").at(-1) || url.pathname;
} catch {
return value.split('/').at(-1)?.split('?')[0] ?? 'resource'
return value.split("/").at(-1)?.split("?")[0] ?? "resource";
}
}
@@ -117,37 +123,39 @@ export function createNativeNavigationProfiler(
runtime: NativeRouterRuntime,
options: NativeProfilerOptions = {},
): NativeNavigationProfiler {
const mutableRecording = ref(false)
const recording = readonly(mutableRecording)
const maxFrames = Math.max(300, options.maxFrames ?? 30_000)
let startedAt = 0
let endedAt = 0
let startedAtIso = ''
let previousFrame: number | undefined
let animationFrame = 0
let frames: NativeProfilerFrame[] = []
let events: NativeDiagnosticEvent[] = []
let performanceEntries: NativeProfilerPerformanceEntry[] = []
let visibility: Array<{ at: number; state: DocumentVisibilityState }> = []
let observers: PerformanceObserver[] = []
const mutableRecording = ref(false);
const recording = readonly(mutableRecording);
const maxFrames = Math.max(300, options.maxFrames ?? 30_000);
let startedAt = 0;
let endedAt = 0;
let startedAtIso = "";
let previousFrame: number | undefined;
let animationFrame = 0;
let frames: NativeProfilerFrame[] = [];
let events: NativeDiagnosticEvent[] = [];
let performanceEntries: NativeProfilerPerformanceEntry[] = [];
let visibility: Array<{ at: number; state: DocumentVisibilityState }> = [];
let observers: PerformanceObserver[] = [];
const relative = (timestamp: number) => round(Math.max(0, timestamp - startedAt), 3)
const relative = (timestamp: number) =>
round(Math.max(0, timestamp - startedAt), 3);
const removeDiagnostic = runtime.onDiagnostic((event) => {
if (!mutableRecording.value) return
const details = event.type === 'transaction-start' && typeof document !== 'undefined'
? {
...event.details,
mountedViews: runtime.cacheStats.value.mounted,
}
: event.details
events.push({ ...event, timestamp: relative(event.timestamp), details })
})
if (!mutableRecording.value) return;
const details =
event.type === "transaction-start" && typeof document !== "undefined"
? {
...event.details,
mountedViews: runtime.cacheStats.value.mounted,
}
: event.details;
events.push({ ...event, timestamp: relative(event.timestamp), details });
});
const sampleFrame = (timestamp: number) => {
if (!mutableRecording.value) return
if (!mutableRecording.value) return;
if (previousFrame !== undefined && frames.length < maxFrames) {
const transaction = runtime.transaction.value
const transaction = runtime.transaction.value;
frames.push({
at: relative(timestamp),
delta: round(timestamp - previousFrame, 3),
@@ -155,98 +163,123 @@ export function createNativeNavigationProfiler(
transactionId: transaction?.id,
phase: transaction?.phase,
progress: transaction ? round(transaction.progress, 4) : undefined,
})
});
}
previousFrame = timestamp
animationFrame = window.requestAnimationFrame(sampleFrame)
}
previousFrame = timestamp;
animationFrame = window.requestAnimationFrame(sampleFrame);
};
const observe = (type: NativeProfilerPerformanceEntry['type']) => {
if (typeof PerformanceObserver === 'undefined') return
if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return
const observe = (type: NativeProfilerPerformanceEntry["type"]) => {
if (typeof PerformanceObserver === "undefined") return;
if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return;
try {
const observer = new PerformanceObserver((list) => {
if (!mutableRecording.value) return
if (!mutableRecording.value) return;
for (const entry of list.getEntries()) {
const extra = entry as PerformanceEntry & {
value?: number
hadRecentInput?: boolean
transferSize?: number
interactionId?: number
}
value?: number;
hadRecentInput?: boolean;
transferSize?: number;
interactionId?: number;
};
performanceEntries.push({
type,
at: relative(entry.startTime),
duration: round(entry.duration, 3),
name: type === 'resource'
? resourceName(entry.name)
: type === 'event'
? entry.name
: undefined,
name:
type === "resource"
? resourceName(entry.name)
: type === "event"
? entry.name
: undefined,
value: extra.value,
size: extra.transferSize,
hadRecentInput: extra.hadRecentInput,
})
});
}
})
observer.observe(type === 'event'
? { type, buffered: false, durationThreshold: 16 } as PerformanceObserverInit
: { type, buffered: false } as PerformanceObserverInit)
observers.push(observer)
});
observer.observe(
type === "event"
? ({
type,
buffered: false,
durationThreshold: 16,
} as PerformanceObserverInit)
: ({ type, buffered: false } as PerformanceObserverInit),
);
observers.push(observer);
} catch {
// Performance entry support differs between Safari, Chromium, and hosts.
}
}
};
const onVisibility = () => {
if (mutableRecording.value) visibility.push({ at: relative(performance.now()), state: document.visibilityState })
}
if (mutableRecording.value)
visibility.push({
at: relative(performance.now()),
state: document.visibilityState,
});
};
const stopSampling = () => {
if (animationFrame) window.cancelAnimationFrame(animationFrame)
animationFrame = 0
for (const observer of observers) observer.disconnect()
observers = []
document.removeEventListener('visibilitychange', onVisibility)
}
if (animationFrame) window.cancelAnimationFrame(animationFrame);
animationFrame = 0;
for (const observer of observers) observer.disconnect();
observers = [];
document.removeEventListener("visibilitychange", onVisibility);
};
const transactionSummaries = (baseline: number) => {
const starts = new Map<number, NativeDiagnosticEvent>()
const ends = new Map<number, NativeDiagnosticEvent>()
const starts = new Map<number, NativeDiagnosticEvent>();
const ends = new Map<number, NativeDiagnosticEvent>();
for (const event of events) {
if (event.transactionId == null) continue
if (event.type === 'transaction-start') starts.set(event.transactionId, event)
if (event.type === 'transaction-end') ends.set(event.transactionId, event)
if (event.transactionId == null) continue;
if (event.type === "transaction-start")
starts.set(event.transactionId, event);
if (event.type === "transaction-end")
ends.set(event.transactionId, event);
}
return [...starts].map(([id, start]) => {
const end = ends.get(id)
const samples = frames.filter((frame) => frame.transactionId === id).map((frame) => frame.delta)
const end = ends.get(id);
const samples = frames
.filter((frame) => frame.transactionId === id)
.map((frame) => frame.delta);
return {
id,
route: start.route,
kind: String(start.details?.kind ?? ''),
kind: String(start.details?.kind ?? ""),
cold: Boolean(start.details?.cold),
outcome: end?.details?.outcome ? String(end.details.outcome) : undefined,
outcome: end?.details?.outcome
? String(end.details.outcome)
: undefined,
duration: end ? round(end.timestamp - start.timestamp, 3) : undefined,
frames: samples.length,
slowFrames: samples.filter((delta) => delta > baseline * 1.5).length,
p95FrameMs: samples.length ? round(percentile(samples, .95), 3) : undefined,
p95FrameMs: samples.length
? round(percentile(samples, 0.95), 3)
: undefined,
maxFrameMs: samples.length ? round(Math.max(...samples), 3) : undefined,
}
})
}
};
});
};
const snapshot = (): NativeProfilerReport => {
const duration = startedAt ? (mutableRecording.value ? performance.now() : endedAt || performance.now()) - startedAt : 0
const deltas = frames.map((frame) => frame.delta).filter((delta) => delta > 0 && delta < 250)
const baseline = Math.max(4, percentile(deltas, .1) || 16.667)
const totalFrameTime = deltas.reduce((sum, value) => sum + value, 0)
const duration = startedAt
? (mutableRecording.value
? performance.now()
: endedAt || performance.now()) - startedAt
: 0;
const deltas = frames
.map((frame) => frame.delta)
.filter((delta) => delta > 0 && delta < 250);
const baseline = Math.max(4, percentile(deltas, 0.1) || 16.667);
const totalFrameTime = deltas.reduce((sum, value) => sum + value, 0);
const shifts = performanceEntries
.filter((entry) => entry.type === 'layout-shift' && !entry.hadRecentInput)
.reduce((sum, entry) => sum + (entry.value ?? 0), 0)
const nav = navigator as Navigator & { deviceMemory?: number }
.filter((entry) => entry.type === "layout-shift" && !entry.hadRecentInput)
.reduce((sum, entry) => sum + (entry.value ?? 0), 0);
const nav = navigator as Navigator & { deviceMemory?: number };
return {
schema: 'native-vue-router-profile@1',
schema: "native-vue-router-profile@1",
startedAt: startedAtIso || new Date().toISOString(),
duration: round(duration, 3),
environment: {
@@ -256,7 +289,9 @@ export function createNativeNavigationProfiler(
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
},
displayMode: window.matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser',
displayMode: window.matchMedia("(display-mode: standalone)").matches
? "standalone"
: "browser",
visibility: document.visibilityState,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: nav.deviceMemory,
@@ -266,14 +301,19 @@ export function createNativeNavigationProfiler(
frames: frames.length,
estimatedRefreshMs: round(baseline, 3),
estimatedRefreshHz: round(1000 / baseline, 1),
averageFps: round(totalFrameTime ? deltas.length * 1000 / totalFrameTime : 0, 1),
p95FrameMs: round(percentile(deltas, .95), 3),
averageFps: round(
totalFrameTime ? (deltas.length * 1000) / totalFrameTime : 0,
1,
),
p95FrameMs: round(percentile(deltas, 0.95), 3),
maxFrameMs: round(deltas.length ? Math.max(...deltas) : 0, 3),
framesOver20ms: deltas.filter((delta) => delta > 20).length,
framesOver34ms: deltas.filter((delta) => delta > 34).length,
framesOver50ms: deltas.filter((delta) => delta > 50).length,
droppedFrames: deltas.filter((delta) => delta > baseline * 1.5).length,
longTasks: performanceEntries.filter((entry) => entry.type === 'longtask').length,
longTasks: performanceEntries.filter(
(entry) => entry.type === "longtask",
).length,
cumulativeLayoutShift: round(shifts, 5),
},
transactions: transactionSummaries(baseline),
@@ -281,40 +321,40 @@ export function createNativeNavigationProfiler(
frames: [...frames],
performanceEntries: [...performanceEntries],
visibility: [...visibility],
}
}
};
};
const clear = () => {
frames = []
events = []
performanceEntries = []
visibility = []
previousFrame = undefined
endedAt = 0
}
frames = [];
events = [];
performanceEntries = [];
visibility = [];
previousFrame = undefined;
endedAt = 0;
};
const start = () => {
if (mutableRecording.value) return
clear()
startedAt = performance.now()
endedAt = 0
startedAtIso = new Date().toISOString()
mutableRecording.value = true
visibility.push({ at: 0, state: document.visibilityState })
document.addEventListener('visibilitychange', onVisibility)
observe('longtask')
observe('layout-shift')
observe('resource')
observe('event')
animationFrame = window.requestAnimationFrame(sampleFrame)
}
if (mutableRecording.value) return;
clear();
startedAt = performance.now();
endedAt = 0;
startedAtIso = new Date().toISOString();
mutableRecording.value = true;
visibility.push({ at: 0, state: document.visibilityState });
document.addEventListener("visibilitychange", onVisibility);
observe("longtask");
observe("layout-shift");
observe("resource");
observe("event");
animationFrame = window.requestAnimationFrame(sampleFrame);
};
const stop = () => {
endedAt = performance.now()
mutableRecording.value = false
stopSampling()
return snapshot()
}
endedAt = performance.now();
mutableRecording.value = false;
stopSampling();
return snapshot();
};
return {
recording,
@@ -324,9 +364,9 @@ export function createNativeNavigationProfiler(
clear,
toJSON: (report = snapshot()) => JSON.stringify(report, null, 2),
dispose() {
mutableRecording.value = false
stopSampling()
removeDiagnostic()
mutableRecording.value = false;
stopSampling();
removeDiagnostic();
},
}
};
}

View File

@@ -1,372 +1,481 @@
import { createApp, defineComponent, nextTick } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'
} from "./runtime";
import { createNativeNavigationProfiler } from "./profiler";
const Page = defineComponent({ template: '<div>page</div>' })
const Page = defineComponent({ template: "<div>page</div>" });
async function harness(blockB: boolean | 'redirect' = false) {
async function harness(blockB: boolean | "redirect" = false) {
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' } } },
{ 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: 2 } })
const app = createApp(Page)
app.use(router)
app.use(native)
await nextTick()
return { router, native }
});
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: 2 } });
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)
})
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)
})
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)
})
})
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()
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()
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()
})
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("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("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("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("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("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("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("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' })
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", "/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()
})
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')
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()
})
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'])
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 })
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)
})
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()
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'))
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()
})
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)
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 })
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()
})
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 })
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({
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)
})
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 })
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({
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)
})
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()
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({
expect(
native.entries.value.find((entry) => entry.key === pushedKey),
).toMatchObject({
mounted: false,
status: 'evicted',
evictionReason: 'popped',
})
})
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')
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({
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()
})
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')
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({
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',
})
})
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)
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({
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()
})
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')
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()
})
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')
await native.present('/modal', 'sheet')
it("dismisses with the presented route animation regardless of the route below it", async () => {
const { native } = await harness();
await native.push("/b");
await native.present("/modal", "sheet");
await native.beginInteractive('dismiss')
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'sheet' })
await native.cancelInteractive()
})
await native.beginInteractive("dismiss");
expect(native.transaction.value).toMatchObject({
direction: "back",
presentation: "sheet",
});
await native.cancelInteractive();
});
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("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("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')
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
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()
})
})
expect(router.currentRoute.value.path).toBe("/a");
expect(native.canGoBack.value).toBe(false);
expect(await native.beginInteractive("pop")).toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@ body,
z-index: 2147483646;
inset: 0;
background: #000;
content: '';
content: "";
opacity: 0;
pointer-events: none;
will-change: opacity;
@@ -65,58 +65,85 @@ body,
visibility: visible;
}
.nvr-view--from { z-index: 3; }
.nvr-view--to { z-index: 2; }
.nvr-view--from {
z-index: 3;
}
.nvr-view--to {
z-index: 2;
}
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from {
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-view--from,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
.nvr-view--from {
transform: translate3d(calc(var(--native-progress) * -28%), 0, 0);
}
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from::after,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from::after {
opacity: calc(var(--native-progress) * .12);
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-view--from::after,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
.nvr-view--from::after {
opacity: calc(var(--native-progress) * 0.12);
}
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--to,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--to {
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
.nvr-view--to,
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
.nvr-view--to {
z-index: 4;
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
box-shadow: -18px 0 42px rgba(0, 0, 0, .28);
box-shadow: -18px 0 42px rgba(0, 0, 0, 0.28);
}
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--from {
.nvr-router-view:is(
[data-native-presentation="push"],
[data-native-presentation="reveal"]
)[data-native-direction="back"]
.nvr-view--from {
z-index: 4;
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
box-shadow: -18px 0 42px rgba(0, 0, 0, .25);
box-shadow: -18px 0 42px rgba(0, 0, 0, 0.25);
}
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to {
.nvr-router-view:is(
[data-native-presentation="push"],
[data-native-presentation="reveal"]
)[data-native-direction="back"]
.nvr-view--to {
transform: translate3d(calc((var(--native-progress) - 1) * 28%), 0, 0);
}
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to::after {
opacity: calc((1 - var(--native-progress)) * .12);
.nvr-router-view:is(
[data-native-presentation="push"],
[data-native-presentation="reveal"]
)[data-native-direction="back"]
.nvr-view--to::after {
opacity: calc((1 - var(--native-progress)) * 0.12);
}
/* Sibling routes are adjacent pages, not a foreground/background stack. */
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"] .nvr-view--from {
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"]
.nvr-view--from {
transform: translate3d(calc(var(--native-progress) * -100%), 0, 0);
}
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"] .nvr-view--to {
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"]
.nvr-view--to {
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
}
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"] .nvr-view--from {
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"]
.nvr-view--from {
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
}
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"] .nvr-view--to {
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"]
.nvr-view--to {
transform: translate3d(calc((var(--native-progress) - 1) * 100%), 0, 0);
}
.nvr-router-view[data-native-presentation="slide"] :is(.nvr-view--from, .nvr-view--to) {
.nvr-router-view[data-native-presentation="slide"]
:is(.nvr-view--from, .nvr-view--to) {
z-index: 3;
box-shadow: none;
}
@@ -125,19 +152,20 @@ body,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
z-index: 4;
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
border-radius: calc((1 - var(--native-progress)) * 24px) calc((1 - var(--native-progress)) * 24px) 0 0;
box-shadow: 0 -24px 60px rgba(0, 0, 0, .34);
border-radius: calc((1 - var(--native-progress)) * 24px)
calc((1 - var(--native-progress)) * 24px) 0 0;
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
}
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from {
transform: scale(calc(1 - var(--native-progress) * .04));
transform: scale(calc(1 - var(--native-progress) * 0.04));
border-radius: calc(var(--native-progress) * 18px);
}
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from::after,
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from::after {
opacity: calc(var(--native-progress) * .24);
opacity: calc(var(--native-progress) * 0.24);
}
.nvr-router-view[data-native-presentation="fade"] .nvr-view--from {
@@ -158,25 +186,35 @@ body,
touch-action: pan-x pinch-zoom;
}
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--from,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] .nvr-view--from {
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
.nvr-view--from,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--from {
z-index: 4;
transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
border-radius: 22px 22px 0 0;
}
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--to,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] .nvr-view--to {
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
.nvr-view--to,
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
.nvr-view--to {
z-index: 2;
transform: scale(calc(.96 + var(--native-progress) * .04));
transform: scale(calc(0.96 + var(--native-progress) * 0.04));
border-radius: calc((1 - var(--native-progress)) * 18px);
box-shadow: none;
}
.nvr-router-view:is([data-native-presentation="modal"], [data-native-presentation="sheet"])[data-native-direction="back"] .nvr-view--to::after {
opacity: calc((1 - var(--native-progress)) * .24);
.nvr-router-view:is(
[data-native-presentation="modal"],
[data-native-presentation="sheet"]
)[data-native-direction="back"]
.nvr-view--to::after {
opacity: calc((1 - var(--native-progress)) * 0.24);
}
@media (prefers-reduced-motion: reduce) {
.nvr-view { will-change: auto; }
.nvr-view {
will-change: auto;
}
}

View File

@@ -1,196 +1,224 @@
import type { App, CSSProperties } from 'vue'
import type { App, CSSProperties } from "vue";
import type {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
Router,
} from 'vue-router'
} from "vue-router";
export type NativePresentationName =
| 'push'
| 'reveal'
| 'slide'
| 'fade'
| 'modal'
| 'sheet'
| 'none'
| (string & {})
| "push"
| "reveal"
| "slide"
| "fade"
| "modal"
| "sheet"
| "none"
| (string & {});
export type NativeGestureKind = 'push' | 'pop' | 'sibling' | 'present' | 'dismiss'
export type NativeDirection = 'forward' | 'back' | 'up' | 'down'
export type NativeViewStatus = 'active' | 'inactive' | 'preview' | 'evicted'
export type NativeViewRole = 'active' | 'inactive' | 'from' | 'to'
export type NativeCachePolicy = boolean | 'pin'
export type NativeGestureKind =
"push" | "pop" | "sibling" | "present" | "dismiss";
export type NativeDirection = "forward" | "back" | "up" | "down";
export type NativeViewStatus = "active" | "inactive" | "preview" | "evicted";
export type NativeViewRole = "active" | "inactive" | "from" | "to";
export type NativeCachePolicy = boolean | "pin";
export type NativeEvictionReason =
| 'cache-disabled'
| 'cache-limit'
| 'navigation-rejected'
| 'popped'
| 'manual'
| 'trimmed'
| 'memory-pressure'
| "cache-disabled"
| "cache-limit"
| "navigation-rejected"
| "popped"
| "manual"
| "trimmed"
| "memory-pressure";
export type NativeDiagnosticEventType =
| 'route-load-start'
| 'route-load-end'
| 'transaction-start'
| 'view-prepare-start'
| 'view-prepare-end'
| 'commit-start'
| 'transaction-end'
| 'view-evicted'
| "route-load-start"
| "route-load-end"
| "transaction-start"
| "view-prepare-start"
| "view-prepare-end"
| "commit-start"
| "transaction-end"
| "view-evicted";
export interface NativeDiagnosticEvent {
type: NativeDiagnosticEventType
type: NativeDiagnosticEventType;
/** Monotonic `performance.now()` timestamp. */
timestamp: number
attempt?: number
transactionId?: number
timestamp: number;
attempt?: number;
transactionId?: number;
/** Route record name or declared path pattern; params and query values are omitted. */
route?: string
duration?: number
details?: Record<string, string | number | boolean | undefined>
route?: string;
duration?: number;
details?: Record<string, string | number | boolean | undefined>;
}
export interface NativeRouteOptions {
navigator?: string
presentation?: NativePresentationName
transition?: NativePresentationName
parent?: RouteLocationRaw | ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw)
siblingGroup?: string
siblingOrder?: number
siblingHistory?: 'push' | 'replace'
navigator?: string;
presentation?: NativePresentationName;
transition?: NativePresentationName;
parent?:
| RouteLocationRaw
| ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw);
siblingGroup?: string;
siblingOrder?: number;
siblingHistory?: "push" | "replace";
/** `false` disables retention; `pin` exempts the route from LRU trimming. */
cache?: NativeCachePolicy
gesture?: boolean | 'edge' | 'full'
cache?: NativeCachePolicy;
gesture?: boolean | "edge" | "full";
}
declare module 'vue-router' {
declare module "vue-router" {
interface RouteMeta {
native?: NativeRouteOptions
native?: NativeRouteOptions;
}
}
export interface NativeViewEntry {
key: string
route: RouteLocationNormalizedLoaded
status: NativeViewStatus
mounted: boolean
synthetic: boolean
key: string;
route: RouteLocationNormalizedLoaded;
status: NativeViewStatus;
mounted: boolean;
synthetic: boolean;
/** True once Vue Router has made this route authoritative. */
committed: boolean
lastUsed: number
scrollX: number
scrollY: number
evictionReason?: NativeEvictionReason
committed: boolean;
lastUsed: number;
scrollX: number;
scrollY: number;
evictionReason?: NativeEvictionReason;
}
export interface NativeCacheStats {
maxInactive: number
descriptors: number
mounted: number
inactive: number
pinned: number
evicted: number
totalEvictions: number
lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
maxInactive: number;
descriptors: number;
mounted: number;
inactive: number;
pinned: number;
evicted: number;
totalEvictions: number;
lastEviction?: { key: string; route: string; reason: NativeEvictionReason };
}
export interface NativeViewLifecycle {
readonly key: string
readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>
readonly status: Readonly<{ value: NativeViewStatus }>
readonly role: Readonly<{ value: NativeViewRole }>
readonly isActive: Readonly<{ value: boolean }>
readonly isVisible: Readonly<{ value: boolean }>
readonly isPreview: Readonly<{ value: boolean }>
readonly isCached: Readonly<{ value: boolean }>
readonly evictionReason: Readonly<{ value: NativeEvictionReason | undefined }>
readonly key: string;
readonly route: Readonly<{ value: RouteLocationNormalizedLoaded }>;
readonly status: Readonly<{ value: NativeViewStatus }>;
readonly role: Readonly<{ value: NativeViewRole }>;
readonly isActive: Readonly<{ value: boolean }>;
readonly isVisible: Readonly<{ value: boolean }>;
readonly isPreview: Readonly<{ value: boolean }>;
readonly isCached: Readonly<{ value: boolean }>;
readonly evictionReason: Readonly<{
value: NativeEvictionReason | undefined;
}>;
}
export interface NativeSourceRect {
top: number
left: number
width: number
height: number
viewportWidth: number
viewportHeight: number
top: number;
left: number;
width: number;
height: number;
viewportWidth: number;
viewportHeight: number;
}
export interface NativeTransaction {
id: number
kind: NativeGestureKind
direction: NativeDirection
presentation: NativePresentationName
fromKey: string
toKey: string
progress: number
velocity: number
phase: 'candidate' | 'interactive' | 'settling' | 'committing' | 'cancelled'
replace: boolean
sourceRect?: NativeSourceRect
id: number;
kind: NativeGestureKind;
direction: NativeDirection;
presentation: NativePresentationName;
fromKey: string;
toKey: string;
progress: number;
velocity: number;
phase: "candidate" | "interactive" | "settling" | "committing" | "cancelled";
replace: boolean;
sourceRect?: NativeSourceRect;
}
export interface NativePresentationContext {
progress: number
role: 'from' | 'to'
direction: NativeDirection
sourceRect?: NativeSourceRect
progress: number;
role: "from" | "to";
direction: NativeDirection;
sourceRect?: NativeSourceRect;
}
export interface NativePresentationDefinition {
name: NativePresentationName
axis?: 'x' | 'y'
layerStyle?: (context: NativePresentationContext) => CSSProperties
name: NativePresentationName;
axis?: "x" | "y";
layerStyle?: (context: NativePresentationContext) => CSSProperties;
}
export interface NativePlatformAdapter {
name: string
install?: (runtime: NativeRouterRuntime) => void | (() => void) | Promise<void | (() => void)>
haptic?: (event: 'selection' | 'commit' | 'cancel') => void | Promise<void>
exitAtRoot?: () => void | Promise<void>
name: string;
install?: (
runtime: NativeRouterRuntime,
) => void | (() => void) | Promise<void | (() => void)>;
haptic?: (event: "selection" | "commit" | "cancel") => void | Promise<void>;
exitAtRoot?: () => void | Promise<void>;
}
export interface NativeRouterOptions {
router: Router
cache?: { maxInactive?: number }
edgeWidth?: number
platform?: NativePlatformAdapter
presentations?: NativePresentationDefinition[]
router: Router;
cache?: { maxInactive?: number };
edgeWidth?: number;
platform?: NativePlatformAdapter;
presentations?: NativePresentationDefinition[];
}
export interface NativeNavigationOptions {
presentation?: NativePresentationName
replace?: boolean
direction?: NativeDirection
sourceRect?: NativeSourceRect
presentation?: NativePresentationName;
replace?: boolean;
direction?: NativeDirection;
sourceRect?: NativeSourceRect;
}
export interface NativeRouterRuntime {
readonly router: Router
readonly entries: Readonly<{ value: readonly NativeViewEntry[] }>
readonly activeKey: Readonly<{ value: string }>
readonly transaction: Readonly<{ value: NativeTransaction | null }>
readonly canGoBack: Readonly<{ value: boolean }>
readonly cacheStats: Readonly<{ value: NativeCacheStats }>
install(app: App): void
push(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
replace(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
sibling(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
pop(): Promise<boolean>
present(to: RouteLocationRaw, presentation?: NativePresentationName): Promise<boolean>
dismiss(): Promise<boolean>
preload(to: RouteLocationRaw): Promise<RouteLocationNormalizedLoaded>
beginInteractive(kind: NativeGestureKind, to?: RouteLocationRaw, options?: NativeNavigationOptions): Promise<number | null>
updateInteractive(progress: number, velocity?: number): void
finishInteractive(forceCommit?: boolean): Promise<boolean>
cancelInteractive(): Promise<void>
readonly router: Router;
readonly entries: Readonly<{ value: readonly NativeViewEntry[] }>;
readonly activeKey: Readonly<{ value: string }>;
readonly transaction: Readonly<{ value: NativeTransaction | null }>;
readonly canGoBack: Readonly<{ value: boolean }>;
readonly cacheStats: Readonly<{ value: NativeCacheStats }>;
install(app: App): void;
push(
to: RouteLocationRaw,
options?: NativeNavigationOptions,
): Promise<boolean>;
replace(
to: RouteLocationRaw,
options?: NativeNavigationOptions,
): Promise<boolean>;
sibling(
to: RouteLocationRaw,
options?: NativeNavigationOptions,
): Promise<boolean>;
pop(): Promise<boolean>;
present(
to: RouteLocationRaw,
presentation?: NativePresentationName,
): Promise<boolean>;
dismiss(): Promise<boolean>;
preload(to: RouteLocationRaw): Promise<RouteLocationNormalizedLoaded>;
beginInteractive(
kind: NativeGestureKind,
to?: RouteLocationRaw,
options?: NativeNavigationOptions,
): Promise<number | null>;
updateInteractive(progress: number, velocity?: number): void;
finishInteractive(forceCommit?: boolean): Promise<boolean>;
cancelInteractive(): Promise<void>;
/** Unmount every inactive instance matching this location. Active and transitioning views are never unloaded. */
unload(to: RouteLocationRaw): number
unload(to: RouteLocationRaw): number;
/** Unmount inactive cached views while retaining route/history descriptors. */
trimCache(options?: { includePinned?: boolean; reason?: NativeEvictionReason }): void
trimCache(options?: {
includePinned?: boolean;
reason?: NativeEvictionReason;
}): void;
/** Subscribe to timing-safe runtime diagnostics. No per-frame events are emitted here. */
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void): () => void
registerPresentation(definition: NativePresentationDefinition): void
presentationFor(name: NativePresentationName): NativePresentationDefinition | undefined
dispose(): void
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void): () => void;
registerPresentation(definition: NativePresentationDefinition): void;
presentationFor(
name: NativePresentationName,
): NativePresentationDefinition | undefined;
dispose(): void;
}

View File

@@ -1,10 +1,16 @@
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import { defineConfig } from "vite";
import { resolve } from "node:path";
export default defineConfig({
build: {
outDir: 'dist', emptyOutDir: true,
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index', cssFileName: 'style' },
rollupOptions: { external: ['vue', 'vue-router'] },
outDir: "dist",
emptyOutDir: true,
lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
cssFileName: "style",
},
rollupOptions: { external: ["vue", "vue-router"] },
},
})
});