Add profiler
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user