Add profiler

This commit is contained in:
2026-07-22 01:03:27 +10:00
parent db864af147
commit c27e5906ca
12 changed files with 634 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
export * from './types'
export * from './runtime'
export * from './components'
export * from './profiler'
import './style.css'
declare module '@vue/runtime-core' {

View File

@@ -0,0 +1,332 @@
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>
/** Maximum rAF samples retained. Defaults to 30,000 (about eight minutes at 60 Hz). */
maxFrames?: number
}
export interface NativeProfilerFrame {
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
}
export interface NativeProfilerTransactionSummary {
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
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>
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 }>
}
export interface NativeNavigationProfiler {
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
}
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
}
function routeLabel(runtime: NativeRouterRuntime) {
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
} catch {
return value.split('/').at(-1)?.split('?')[0] ?? 'resource'
}
}
/**
* Opt-in navigation profiler. It installs no rAF loop or PerformanceObservers
* until `start()` is called and removes all sampling work in `stop()`.
*/
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 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 })
})
const sampleFrame = (timestamp: number) => {
if (!mutableRecording.value) return
if (previousFrame !== undefined && frames.length < maxFrames) {
const transaction = runtime.transaction.value
frames.push({
at: relative(timestamp),
delta: round(timestamp - previousFrame, 3),
route: routeLabel(runtime),
transactionId: transaction?.id,
phase: transaction?.phase,
progress: transaction ? round(transaction.progress, 4) : undefined,
})
}
previousFrame = timestamp
animationFrame = window.requestAnimationFrame(sampleFrame)
}
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
for (const entry of list.getEntries()) {
const extra = entry as PerformanceEntry & {
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,
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)
} catch {
// Performance entry support differs between Safari, Chromium, and hosts.
}
}
const onVisibility = () => {
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)
}
const transactionSummaries = (baseline: number) => {
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)
}
return [...starts].map(([id, start]) => {
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 ?? ''),
cold: Boolean(start.details?.cold),
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,
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 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 }
return {
schema: 'native-vue-router-profile@1',
startedAt: startedAtIso || new Date().toISOString(),
duration: round(duration, 3),
environment: {
userAgent: navigator.userAgent,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
},
displayMode: window.matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser',
visibility: document.visibilityState,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: nav.deviceMemory,
},
metadata: options.metadata ?? {},
summary: {
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),
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,
cumulativeLayoutShift: round(shifts, 5),
},
transactions: transactionSummaries(baseline),
events: [...events],
frames: [...frames],
performanceEntries: [...performanceEntries],
visibility: [...visibility],
}
}
const clear = () => {
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)
}
const stop = () => {
endedAt = performance.now()
mutableRecording.value = false
stopSampling()
return snapshot()
}
return {
recording,
start,
stop,
snapshot,
clear,
toJSON: (report = snapshot()) => JSON.stringify(report, null, 2),
dispose() {
mutableRecording.value = false
stopSampling()
removeDiagnostic()
},
}
}

View File

@@ -7,6 +7,7 @@ import {
shouldCommitGesture,
springTimeScaleForVelocity,
} from './runtime'
import { createNativeNavigationProfiler } from './profiler'
const Page = defineComponent({ template: '<div>page</div>' })
@@ -23,6 +24,7 @@ async function harness(blockB: boolean | 'redirect' = false) {
{ 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)
@@ -57,6 +59,28 @@ describe('gesture decisions', () => {
})
describe('native router transactions', () => {
it('exports opt-in frame diagnostics without route params or query values', async () => {
const { native } = await harness()
const profiler = createNativeNavigationProfiler(native, { metadata: { build: 'test' } })
profiler.start()
await native.push('/item/private-id?token=secret')
const report = profiler.stop()
expect(report.schema).toBe('native-vue-router-profile@1')
expect(report.metadata).toEqual({ build: 'test' })
expect(report.events.map((event) => event.type)).toEqual(expect.arrayContaining([
'route-load-start',
'route-load-end',
'transaction-start',
'transaction-end',
]))
expect(report.transactions).toMatchObject([{ route: '/item/:id', cold: true, outcome: 'committed' }])
expect(profiler.toJSON(report)).not.toContain('private-id')
expect(profiler.toJSON(report)).not.toContain('secret')
profiler.dispose()
})
it('preloads a target without changing URL history', async () => {
const { router, native } = await harness()
const id = await native.beginInteractive('push', '/b')

View File

@@ -13,9 +13,12 @@ import {
type NavigationFailure,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
type RouteLocationResolved,
} from 'vue-router'
import type {
NativeDirection,
NativeDiagnosticEvent,
NativeDiagnosticEventType,
NativeEvictionReason,
NativeGestureKind,
NativeNavigationOptions,
@@ -36,6 +39,12 @@ function now() {
return typeof performance === 'undefined' ? Date.now() : performance.now()
}
function diagnosticRoute(route: RouteLocationNormalizedLoaded | RouteLocationResolved) {
return route.name != null
? String(route.name)
: route.matched.at(-1)?.path ?? route.path
}
function entryFor(route: RouteLocationNormalizedLoaded, status: NativeViewEntry['status'], synthetic = false): NativeViewEntry {
return {
key: `${route.fullPath}::${++entrySequence}`,
@@ -101,6 +110,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
private totalEvictions = 0
private lastEviction?: { key: string; route: string; reason: NativeEvictionReason }
private memoryPressureCleanup?: () => void
private readonly diagnosticListeners = new Set<(event: NativeDiagnosticEvent) => void>()
constructor(options: NativeRouterOptions) {
this.router = options.router
@@ -171,7 +181,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
async preload(to: RouteLocationRaw) {
const resolved = this.router.resolve(to)
return await loadRouteLocation(resolved)
return await this.loadResolvedRoute(resolved)
}
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
@@ -237,14 +247,14 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
const parent = from.route.meta.native?.parent
if (!parent) return null
const parentLocation = typeof parent === 'function' ? parent(from.route) : parent
const route = await this.preload(parentLocation)
const route = await this.loadResolvedRoute(this.router.resolve(parentLocation), attempt)
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
target = entryFor(route, 'preview', true)
synthetic = true
needsMount = true
this.mutableEntries.value = [...this.mutableEntries.value, target]
} else if (!target.mounted) {
const route = await this.preload(target.route.fullPath)
const route = await this.loadResolvedRoute(this.router.resolve(target.route.fullPath), attempt)
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
target.route = route
target.mounted = true
@@ -257,7 +267,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
if (!to) return null
const resolved = this.router.resolve(to)
if (resolved.fullPath === from.route.fullPath) return null
const route = await loadRouteLocation(resolved)
const route = await this.loadResolvedRoute(resolved, attempt)
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
const replace = options.replace ?? (route.meta.native?.siblingHistory === 'replace')
target = replace ? this.findHistoryEntry(route.fullPath) : undefined
@@ -307,9 +317,32 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
}
if (attempt !== this.beginAttemptSequence || this.mutableTransaction.value) return null
this.mutableTransaction.value = transaction
this.emitDiagnostic('transaction-start', {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
details: {
kind: transaction.kind,
direction: transaction.direction,
presentation: transaction.presentation,
cold: needsMount,
},
})
if (synthetic) target.synthetic = true
if (needsMount) {
const prepareStarted = now()
this.emitDiagnostic('view-prepare-start', {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
})
await this.prepareMountedView()
this.emitDiagnostic('view-prepare-end', {
attempt,
transactionId: transaction.id,
route: diagnosticRoute(target.route),
duration: now() - prepareStarted,
})
if (!this.isCurrent(transaction.id)) return null
}
return transaction.id
@@ -335,6 +368,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
}
this.mutableTransaction.value = { ...current, phase: 'committing' }
this.emitDiagnostic('commit-start', {
transactionId: current.id,
route: this.entryByKey(current.toKey) ? diagnosticRoute(this.entryByKey(current.toKey)!.route) : undefined,
})
void this.platform?.haptic?.('commit')
const navigation = this.commitRoute(current)
this.pendingNavigations.set(current.id, navigation)
@@ -390,6 +427,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
this.touchEntries()
}
onDiagnostic(listener: (event: NativeDiagnosticEvent) => void) {
this.diagnosticListeners.add(listener)
return () => this.diagnosticListeners.delete(listener)
}
registerPresentation(definition: NativePresentationDefinition) {
this.presentations.set(definition.name, definition)
}
@@ -485,6 +527,12 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
private finalizeCancelled(transaction: NativeTransaction, evictTarget = false) {
if (!this.isCurrent(transaction.id)) return
const target = this.entryByKey(transaction.toKey)
this.emitDiagnostic('transaction-end', {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
details: { outcome: evictTarget ? 'rejected' : 'cancelled' },
})
this.clearTransaction()
if (evictTarget) this.discardTarget(transaction.toKey, 'navigation-rejected')
else this.removePreview(transaction.toKey)
@@ -497,6 +545,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
const target = this.entryByKey(transaction.toKey)
if (target && this.router.currentRoute.value.fullPath !== target.route.fullPath) {
// A redirect is already authoritative; discard only the stale preview.
this.emitDiagnostic('transaction-end', {
transactionId: transaction.id,
route: diagnosticRoute(target.route),
details: { outcome: 'redirected' },
})
this.clearTransaction()
this.discardTarget(transaction.toKey, 'navigation-rejected')
this.markStatuses()
@@ -510,6 +563,11 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
target.lastUsed = now()
this.mutableActiveKey.value = target.key
}
this.emitDiagnostic('transaction-end', {
transactionId: transaction.id,
route: target ? diagnosticRoute(target.route) : undefined,
details: { outcome: 'committed' },
})
this.clearTransaction()
this.markStatuses()
this.enforceCache()
@@ -643,6 +701,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
entry.evictionReason = reason
this.totalEvictions += 1
this.lastEviction = { key: entry.key, route: entry.route.fullPath, reason }
this.emitDiagnostic('view-evicted', {
route: diagnosticRoute(entry.route),
details: { reason },
})
}
private shouldRetainInactive(entry: NativeViewEntry) {
@@ -679,6 +741,30 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
}
private async loadResolvedRoute(route: RouteLocationResolved, attempt?: number) {
const started = now()
const label = diagnosticRoute(route)
this.emitDiagnostic('route-load-start', { attempt, route: label })
try {
return await loadRouteLocation(route)
} finally {
this.emitDiagnostic('route-load-end', {
attempt,
route: label,
duration: now() - started,
})
}
}
private emitDiagnostic(
type: NativeDiagnosticEventType,
event: Omit<NativeDiagnosticEvent, 'type' | 'timestamp'> = {},
) {
if (!this.diagnosticListeners.size) return
const diagnostic = { type, timestamp: now(), ...event }
for (const listener of this.diagnosticListeners) listener(diagnostic)
}
private animateProgress(target: number, initialVelocity: number) {
const transaction = this.mutableTransaction.value
if (!transaction) return Promise.resolve()

View File

@@ -29,6 +29,28 @@ export type NativeEvictionReason =
| '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'
export interface NativeDiagnosticEvent {
type: NativeDiagnosticEventType
/** Monotonic `performance.now()` timestamp. */
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>
}
export interface NativeRouteOptions {
navigator?: string
presentation?: NativePresentationName
@@ -166,6 +188,8 @@ export interface NativeRouterRuntime {
unload(to: RouteLocationRaw): number
/** Unmount inactive cached views while retaining route/history descriptors. */
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