Add profiler
This commit is contained in:
21
README.md
21
README.md
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
apps/demo/src/navigation-profiler.ts
Normal file
66
apps/demo/src/navigation-profiler.ts
Normal 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 }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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