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

@@ -93,6 +93,27 @@ Sibling views are lazy rather than pre-mounted: only the initial route exists on
Call `nativeRouter.unload('/some-route')` to manually unmount inactive instances of one location while retaining their lightweight history descriptors. The active route and views participating in a transition are protected.
### Capture frame pacing on a real device
The Navigation Lab contains an opt-in profiler. Tap **Start profiling**, leave the lab, reproduce the choppy navigation once or twice, return to the lab, tap **Stop**, then **Share JSON**. Installed iOS PWAs use the system share sheet; other browsers download the file. Attach that JSON to a bug report.
The core API is also available directly:
```ts
import { createNativeNavigationProfiler } from '@native-vue-router/core'
const profiler = createNativeNavigationProfiler(nativeRouter, {
metadata: { build: import.meta.env.VITE_BUILD_ID },
})
profiler.start()
// Reproduce the navigation issue.
const report = profiler.stop()
const json = profiler.toJSON(report)
```
No rAF loop or browser performance observer runs before `start()`, and `stop()` removes them. Reports contain frame intervals, refresh-rate estimates, per-navigation timing, cold-mount preparation, route loading, cache eviction, visibility changes, and browser-supported Long Task/layout-shift/resource timing. Route params, query values, and application state are omitted.
## Packages
- `@native-vue-router/core` — transactions, route ledger, concurrent views, gestures, caching, and public components/composables.

View File

@@ -275,6 +275,29 @@ test('manually unloads an inactive route through the public API demo', async ({
await expect(page.getByTestId('stories-view')).toHaveCount(0)
})
test('records a navigation frame profile across route changes', async ({ page }) => {
await page.goto('/settings')
await page.getByTestId('profile-start').click()
await expect(page.locator('.profiler-badge')).toBeVisible()
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await page.getByRole('link', { name: /You/ }).click()
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
await page.getByRole('link', { name: /Navigation lab/ }).click()
await expect(page).toHaveURL(/\/settings$/)
await waitForTransition(page)
await page.getByTestId('profile-stop').click()
await expect(page.locator('.profiler-badge')).toHaveCount(0)
await expect(page.getByTestId('profile-status')).toContainText('navigations')
await expect(page.getByTestId('profile-export')).toBeEnabled()
})
test('opens and dismisses the compose sheet', async ({ page }) => {
await page.goto('/inbox')
await page.getByRole('button', { name: 'Compose' }).click()

View File

@@ -4,6 +4,7 @@ import { NativeNavigator, NativeRouterView } from '@native-vue-router/core'
import { NativeTabBar, type NativeTabItem } from '@native-vue-router/preset-native'
import { useRoute } from 'vue-router'
import PwaUpdate from './components/PwaUpdate.vue'
import { profilerRecording } from './navigation-profiler'
const route = useRoute()
const siblingRoutes = ['/inbox', '/stories', '/profile', '/profile/runtime-lab']
@@ -21,6 +22,7 @@ const tabs: NativeTabItem[] = [
<NativeRouterView />
</NativeNavigator>
<NativeTabBar v-if="showTabs" :items="tabs" class="app-tabs" />
<div v-if="profilerRecording" class="profiler-badge" aria-live="polite"><i /> Profiling navigation</div>
<PwaUpdate />
</div>
</template>

View File

@@ -0,0 +1,66 @@
import { ref } from 'vue'
import {
createNativeNavigationProfiler,
type NativeNavigationProfiler,
type NativeProfilerReport,
type NativeRouterRuntime,
} from '@native-vue-router/core'
import { pwaBuildId } from './pwa'
let profiler: NativeNavigationProfiler | undefined
export const profilerRecording = ref(false)
export const profilerHasCapture = ref(false)
function instance(runtime: NativeRouterRuntime) {
profiler ??= createNativeNavigationProfiler(runtime, {
metadata: { app: 'nvr-messenger-demo', build: pwaBuildId },
})
return profiler
}
export function startDemoProfile(runtime: NativeRouterRuntime) {
const active = instance(runtime)
active.start()
profilerRecording.value = true
profilerHasCapture.value = true
}
export function stopDemoProfile(runtime: NativeRouterRuntime) {
const report = instance(runtime).stop()
profilerRecording.value = false
return report
}
export function snapshotDemoProfile(runtime: NativeRouterRuntime) {
return instance(runtime).snapshot()
}
export async function shareDemoProfile(runtime: NativeRouterRuntime, report?: NativeProfilerReport) {
const active = instance(runtime)
const current = report ?? (profilerRecording.value ? stopDemoProfile(runtime) : active.snapshot())
const json = active.toJSON(current)
const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
const filename = `native-vue-router-profile-${stamp}.json`
const file = new File([json], filename, { type: 'application/json' })
const shareNavigator = navigator as Navigator & {
canShare?: (data: ShareData) => boolean
share?: (data: ShareData) => Promise<void>
}
if (shareNavigator.share && shareNavigator.canShare?.({ files: [file] })) {
await shareNavigator.share({
title: 'Native Vue Router performance profile',
text: 'Frame pacing and navigation diagnostics. Route params and query values are omitted.',
files: [file],
})
return { report: current, method: 'shared' as const }
}
const url = URL.createObjectURL(file)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 1_000)
return { report: current, method: 'downloaded' as const }
}

View File

@@ -272,6 +272,11 @@ html[data-pwa-edge-guard="active"] body {
.settings-group b { color: #3dd9aa; font-size: 12px; }.settings-group b.offline { color: #ff7a84; }
.reset-button { display: block; width: calc(100% - 32px); min-height: 48px; margin: 0 16px 30px; border: 1px solid rgba(255,108,118,.2); border-radius: 15px; color: #ff7a84; background: rgba(255,108,118,.07); }
.reset-button--neutral { margin-bottom: 18px; border-color: rgba(124,92,255,.22); color: #ad9fff; background: rgba(124,92,255,.08); }
.profiler-controls { display: grid; grid-template-columns: 1fr .7fr 1fr; gap: 8px; margin: 0 16px 18px; }
.profiler-controls button { min-height: 44px; border: 1px solid rgba(124,92,255,.24); border-radius: 13px; color: #c2b8ff; background: rgba(124,92,255,.1); font-weight: 700; }
.profiler-controls button:disabled { opacity: .38; }
.profiler-badge { position: absolute; z-index: 60; top: calc(8px + env(safe-area-inset-top)); right: 10px; display: flex; align-items: center; gap: 6px; padding: 6px 9px; border: 1px solid rgba(255,108,118,.35); border-radius: 999px; color: #ff9aa2; background: rgba(24,10,13,.9); font-size: 10px; font-weight: 800; pointer-events: none; }
.profiler-badge i { width: 7px; height: 7px; border-radius: 50%; background: #ff6c76; box-shadow: 0 0 9px #ff6c76; }
.empty-state { display: grid; place-items: center; }
.update-toast { position: absolute; z-index: 50; right: 14px; bottom: calc(82px + env(safe-area-inset-bottom)); left: 14px; display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 15px; background: rgba(28,31,40,.96); box-shadow: 0 18px 50px rgba(0,0,0,.4); font-size: 12px; }
.update-toast button { border: 0; color: #a998ff; background: transparent; font-weight: 700; }

View File

@@ -3,11 +3,41 @@ import { ref } from 'vue'
import { useNativeRouter } from '@native-vue-router/core'
import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data'
import {
profilerHasCapture,
profilerRecording,
shareDemoProfile,
startDemoProfile,
stopDemoProfile,
} from '../navigation-profiler'
import { pwaBuildId, pwaEnvironment } from '../pwa'
const store = useDemoStore()
const native = useNativeRouter()
const unloadResult = ref('Unload cached Stories')
const profileStatus = ref(profilerRecording.value ? 'Recording navigation now' : 'Ready to record')
function describeProfile(report: ReturnType<typeof stopDemoProfile>) {
return `${report.transactions.length} navigations · ${report.summary.droppedFrames} dropped frames · ${report.summary.p95FrameMs} ms p95`
}
function startProfile() {
startDemoProfile(native)
profileStatus.value = 'Recording. Leave this page, reproduce the jank, then return here.'
}
function stopProfile() {
profileStatus.value = describeProfile(stopDemoProfile(native))
}
async function exportProfile() {
try {
const result = await shareDemoProfile(native)
profileStatus.value = `${describeProfile(result.report)} · ${result.method}`
} catch (error) {
if ((error as DOMException)?.name !== 'AbortError') profileStatus.value = 'Profile export failed'
}
}
function unloadStories() {
const count = native.unload('/stories')
@@ -31,6 +61,16 @@ function unloadStories() {
<p v-if="pwaEnvironment.ios && !pwaEnvironment.standalone">In Safari, choose Share Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.</p>
<p v-else-if="pwaEnvironment.ios">The leading edge is reserved before WebKit navigation begins. Open a conversation and drag from the extreme left edge to verify the live back preview.</p>
</section>
<section class="settings-group">
<h2>Frame pacing profiler</h2>
<div><span><strong>{{ profilerRecording ? 'Recording' : 'Profiler idle' }}</strong><small data-testid="profile-status">{{ profileStatus }}</small></span><b :class="{ offline: !profilerRecording }">{{ profilerRecording ? 'LIVE' : 'OFF' }}</b></div>
<p>Start here, reproduce the choppy navigation, return here, then stop and export. The JSON includes rAF frame intervals and navigation phases but omits route params, query values, and application data.</p>
</section>
<div class="profiler-controls">
<button type="button" data-testid="profile-start" :disabled="profilerRecording" @click="startProfile">Start profiling</button>
<button type="button" data-testid="profile-stop" :disabled="!profilerRecording" @click="stopProfile">Stop</button>
<button type="button" data-testid="profile-export" :disabled="!profilerHasCapture" @click="exportProfile">Share JSON</button>
</div>
<section class="settings-group">
<h2>Native view cache</h2>
<div><span><strong>Mounted views</strong><small>{{ native.cacheStats.value.inactive }} inactive of {{ native.cacheStats.value.maxInactive }} allowed</small></span><b data-testid="cache-mounted">{{ native.cacheStats.value.mounted }}</b></div>

View File

@@ -85,3 +85,9 @@ onNativeViewEvict((reason) => saveDraft(view.route.value, reason))
```
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation. Application data that must survive eviction belongs in an application store.
## Optional performance profiler
`createNativeNavigationProfiler(runtime)` correlates `requestAnimationFrame` intervals with timing-safe runtime events: route loading, cold view preparation, transaction start/commit/end, and eviction. It estimates the device's actual refresh interval instead of assuming 60 Hz, then flags frame gaps larger than 1.5 times that baseline. Reports also include Long Tasks, layout shifts, event timing, and resource timing when the host implements those Performance Observer entry types.
Safari does not currently expose every Chromium performance entry, so rAF cadence and native-router events are the portable ground truth. Visibility changes are retained because backgrounding or the share sheet can throttle rAF and would otherwise resemble dropped frames. Sampling is completely opt-in and capped at 30,000 frames by default. Route labels use record names or declared path patterns, never params or query values.

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