Add prettier. Format.
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user