Add profiler
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export * from './types'
|
||||
export * from './runtime'
|
||||
export * from './components'
|
||||
export * from './profiler'
|
||||
import './style.css'
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
|
||||
332
packages/core/src/profiler.ts
Normal file
332
packages/core/src/profiler.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user