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

@@ -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>