diff --git a/.changeset/performance-monitor-startup-insights.md b/.changeset/performance-monitor-startup-insights.md new file mode 100644 index 00000000..21c0cc71 --- /dev/null +++ b/.changeset/performance-monitor-startup-insights.md @@ -0,0 +1,7 @@ +--- +'@rozenite/performance-monitor-plugin': minor +--- + +Add first-class startup insights to the Performance Monitor plugin. + +A new Startup tab (first in the tab order) shows Total startup time and the three key launch phases — Native Launch, JS Bundle, and Initial Mount — with proportional bars so you can see at a glance where startup time is spent. Phases that have not yet completed show as "In progress…"; phases absent from the event stream show as "—". The startup data is derived automatically from React Native's buffered native marks, so no extra instrumentation is required. \ No newline at end of file diff --git a/packages/performance-monitor-plugin/src/ui/App.tsx b/packages/performance-monitor-plugin/src/ui/App.tsx index e5078833..b1ddbb66 100644 --- a/packages/performance-monitor-plugin/src/ui/App.tsx +++ b/packages/performance-monitor-plugin/src/ui/App.tsx @@ -32,6 +32,7 @@ import { DetailsSidebar } from './components/DetailsSidebar'; import { SessionDuration } from './components/SessionDuration'; import { ExportModal } from './components/ExportModal'; import { deriveStartupPhases } from './derive-startup-phases'; +import { StartupTab } from './components/StartupTab'; type PerformanceMonitorSession = { sessionStartedAt: number; @@ -264,10 +265,11 @@ export default function PerformanceMonitorPanel() { }} > + Startup Measures ({allMeasures.length}) @@ -293,6 +295,13 @@ export default function PerformanceMonitorPanel() { minHeight: 0, }} > + + + + ({ + name, + startTime, + duration: 0, + entryType: 'react-native-mark', +}); + +const ALL_MARKS = [ + mark('nativeLaunchStart', 100), + mark('nativeLaunchEnd', 300), + mark('runJSBundleStart', 300), + mark('runJSBundleEnd', 600), + mark('initialMountStart', 600), + mark('initialMountEnd', 900), +]; + +describe('deriveStartupSummary', () => { + describe('all three phases complete', () => { + it('returns correct durations for each phase', () => { + const { phases } = deriveStartupSummary(ALL_MARKS); + const [native, js, mount] = phases; + expect(native).toMatchObject({ + name: 'nativeLaunch', + status: 'complete', + duration: 200, + }); + expect(js).toMatchObject({ + name: 'runJSBundle', + status: 'complete', + duration: 300, + }); + expect(mount).toMatchObject({ + name: 'initialMount', + status: 'complete', + duration: 300, + }); + }); + + it('returns correct total duration', () => { + const { total } = deriveStartupSummary(ALL_MARKS); + expect(total).toEqual({ status: 'complete', duration: 800 }); + }); + + it('always returns the three known phases in order', () => { + const { phases } = deriveStartupSummary(ALL_MARKS); + expect(phases.slice(0, 3).map((p) => p.name)).toEqual([ + 'nativeLaunch', + 'runJSBundle', + 'initialMount', + ]); + }); + }); + + describe('incomplete pairs (in-progress)', () => { + it('marks a phase in-progress when only the Start mark has arrived', () => { + const { phases } = deriveStartupSummary([ + mark('nativeLaunchStart', 100), + mark('nativeLaunchEnd', 300), + mark('runJSBundleStart', 300), + // runJSBundleEnd missing + ]); + const js = phases.find((p) => p.name === 'runJSBundle')!; + expect(js.status).toBe('in-progress'); + expect(js.duration).toBeUndefined(); + }); + + it('records startTime on an in-progress phase', () => { + const { phases } = deriveStartupSummary([mark('nativeLaunchStart', 100)]); + const native = phases.find((p) => p.name === 'nativeLaunch')!; + expect(native.startTime).toBe(100); + }); + }); + + describe('missing phases', () => { + it('marks a phase missing when neither Start nor End arrived', () => { + const { phases } = deriveStartupSummary([ + mark('runJSBundleStart', 300), + mark('runJSBundleEnd', 600), + ]); + const native = phases.find((p) => p.name === 'nativeLaunch')!; + const mount = phases.find((p) => p.name === 'initialMount')!; + expect(native.status).toBe('missing'); + expect(mount.status).toBe('missing'); + }); + + it('returns total missing when no marks arrived at all', () => { + const { total } = deriveStartupSummary([]); + expect(total.status).toBe('missing'); + }); + + it('returns total missing when nativeLaunchStart is absent', () => { + const { total } = deriveStartupSummary([ + mark('runJSBundleStart', 300), + mark('runJSBundleEnd', 600), + ]); + expect(total.status).toBe('missing'); + }); + + it('returns all three known phases as missing for empty input', () => { + const { phases } = deriveStartupSummary([]); + expect(phases.slice(0, 3).map((p) => p.status)).toEqual([ + 'missing', + 'missing', + 'missing', + ]); + }); + }); + + describe('unknown phases', () => { + it('appends unknown Start/End pairs after the three known phases', () => { + const { phases } = deriveStartupSummary([ + ...ALL_MARKS, + mark('bridgelessInitialMountStart', 950), + mark('bridgelessInitialMountEnd', 1050), + ]); + expect(phases).toHaveLength(4); + expect(phases[3]).toMatchObject({ + name: 'bridgelessInitialMount', + status: 'complete', + duration: 100, + }); + }); + + it('marks an unknown phase in-progress when only Start arrived', () => { + const { phases } = deriveStartupSummary([mark('customPhaseStart', 200)]); + const custom = phases.find((p) => p.name === 'customPhase')!; + expect(custom.status).toBe('in-progress'); + }); + }); + + describe('total calculation', () => { + it('uses nativeLaunchStart as the reference start', () => { + const { total } = deriveStartupSummary(ALL_MARKS); + // nativeLaunchStart=100, initialMountEnd=900 → 800 + expect(total.duration).toBe(800); + }); + + it('uses the last complete phase end when initialMount is missing', () => { + const { total } = deriveStartupSummary([ + mark('nativeLaunchStart', 100), + mark('nativeLaunchEnd', 300), + mark('runJSBundleStart', 300), + mark('runJSBundleEnd', 600), + // initialMount absent + ]); + // nativeLaunchStart=100, runJSBundleEnd=600 → 500 + expect(total).toMatchObject({ status: 'complete', duration: 500 }); + }); + }); + + describe('out-of-order marks', () => { + it('pairs correctly regardless of mark order in the input', () => { + const { phases } = deriveStartupSummary([ + mark('nativeLaunchEnd', 300), + mark('runJSBundleEnd', 600), + mark('initialMountEnd', 900), + mark('nativeLaunchStart', 100), + mark('runJSBundleStart', 300), + mark('initialMountStart', 600), + ]); + expect(phases[0]).toMatchObject({ + name: 'nativeLaunch', + status: 'complete', + duration: 200, + }); + expect(phases[1]).toMatchObject({ + name: 'runJSBundle', + status: 'complete', + duration: 300, + }); + expect(phases[2]).toMatchObject({ + name: 'initialMount', + status: 'complete', + duration: 300, + }); + }); + }); +}); diff --git a/packages/performance-monitor-plugin/src/ui/components/StartupTab.tsx b/packages/performance-monitor-plugin/src/ui/components/StartupTab.tsx new file mode 100644 index 00000000..311d926d --- /dev/null +++ b/packages/performance-monitor-plugin/src/ui/components/StartupTab.tsx @@ -0,0 +1,138 @@ +import { Box, Flex, Text } from '@radix-ui/themes'; +import type { SerializedPerformanceReactNativeMark } from '../../shared/types'; +import { + deriveStartupSummary, + type StartupPhase, + type StartupTotal, +} from '../derive-startup-summary'; +import { formatDuration } from '../utils'; + +type StartupTabProps = { + reactNativeMarks: SerializedPerformanceReactNativeMark[]; + isSessionActive: boolean; +}; + +const BAR_COLOR = 'hsl(212 100% 48%)'; +const BAR_TRACK_COLOR = 'hsl(0 0% 14.9%)'; + +const DurationCell = ({ phase }: { phase: StartupPhase | StartupTotal }) => { + if (phase.status === 'missing') { + return ( + + — + + ); + } + if (phase.status === 'in-progress') { + return ( + + In progress… + + ); + } + return ( + + {formatDuration(phase.duration!)} + + ); +}; + +const PhaseBar = ({ + phase, + totalDuration, +}: { + phase: StartupPhase; + totalDuration: number | undefined; +}) => { + if ( + phase.status !== 'complete' || + totalDuration === undefined || + totalDuration === 0 + ) { + return null; + } + const pct = Math.min((phase.duration! / totalDuration) * 100, 100); + return ( + + + + ); +}; + +const ROW_STYLE: React.CSSProperties = { + borderBottom: '1px solid hsl(0 0% 14.9%)', + padding: '10px 0', +}; + +export const StartupTab = ({ + reactNativeMarks, + isSessionActive, +}: StartupTabProps) => { + if (!isSessionActive && reactNativeMarks.length === 0) { + return ( + + + Start a session to see startup data + + + ); + } + + const { phases, total } = deriveStartupSummary(reactNativeMarks); + const totalDuration = + total.status === 'complete' ? total.duration : undefined; + + return ( + + {/* Total row */} + + + + Total startup + + + + + + + + {/* Phase rows */} + {phases.map((phase) => ( + + + + {phase.label} + + + + + + + + ))} + + ); +}; diff --git a/packages/performance-monitor-plugin/src/ui/derive-startup-summary.ts b/packages/performance-monitor-plugin/src/ui/derive-startup-summary.ts new file mode 100644 index 00000000..8920070d --- /dev/null +++ b/packages/performance-monitor-plugin/src/ui/derive-startup-summary.ts @@ -0,0 +1,99 @@ +import type { SerializedPerformanceReactNativeMark } from '../shared/types'; + +export type StartupPhaseStatus = 'complete' | 'in-progress' | 'missing'; + +export type StartupPhase = { + name: string; + label: string; + status: StartupPhaseStatus; + startTime?: number; + duration?: number; +}; + +export type StartupTotal = { + status: StartupPhaseStatus; + duration?: number; +}; + +export type StartupSummary = { + phases: StartupPhase[]; + total: StartupTotal; +}; + +const KNOWN_PHASES: { name: string; label: string }[] = [ + { name: 'nativeLaunch', label: 'Native Launch' }, + { name: 'runJSBundle', label: 'JS Bundle' }, + { name: 'initialMount', label: 'Initial Mount' }, +]; + +export const deriveStartupSummary = ( + marks: SerializedPerformanceReactNativeMark[], +): StartupSummary => { + const starts = new Map(); + const ends = new Map(); + + for (const mark of marks) { + if (mark.name.endsWith('Start')) { + starts.set(mark.name.slice(0, -'Start'.length), mark.startTime); + } else if (mark.name.endsWith('End')) { + ends.set(mark.name.slice(0, -'End'.length), mark.startTime); + } + } + + const knownNames = new Set(KNOWN_PHASES.map((p) => p.name)); + + const buildPhase = (name: string, label: string): StartupPhase => { + const startTime = starts.get(name); + const endTime = ends.get(name); + if (startTime === undefined && endTime === undefined) { + return { name, label, status: 'missing' }; + } + if (endTime === undefined) { + return { name, label, status: 'in-progress', startTime }; + } + return { + name, + label, + status: 'complete', + startTime, + duration: endTime - (startTime ?? endTime), + }; + }; + + const knownPhases = KNOWN_PHASES.map(({ name, label }) => + buildPhase(name, label), + ); + + const unknownPhases: StartupPhase[] = []; + for (const [name] of starts) { + if (!knownNames.has(name)) { + unknownPhases.push(buildPhase(name, name)); + } + } + // unknown phases that only have an End mark (no Start) + for (const [name] of ends) { + if (!knownNames.has(name) && !starts.has(name)) { + unknownPhases.push(buildPhase(name, name)); + } + } + + const phases = [...knownPhases, ...unknownPhases]; + + // Total: nativeLaunchStart → the endTime of the last complete known phase, + // falling back to the last complete unknown phase. + const totalStart = starts.get('nativeLaunch'); + const completePhases = phases.filter((p) => p.status === 'complete'); + const lastComplete = completePhases[completePhases.length - 1]; + + let total: StartupTotal; + if (totalStart === undefined) { + total = { status: 'missing' }; + } else if (lastComplete) { + const lastEndTime = lastComplete.startTime! + lastComplete.duration!; + total = { status: 'complete', duration: lastEndTime - totalStart }; + } else { + total = { status: 'in-progress' }; + } + + return { phases, total }; +}; diff --git a/website/src/docs/official-plugins/performance-monitor.mdx b/website/src/docs/official-plugins/performance-monitor.mdx index 4bb3a83a..f7925574 100644 --- a/website/src/docs/official-plugins/performance-monitor.mdx +++ b/website/src/docs/official-plugins/performance-monitor.mdx @@ -10,6 +10,7 @@ The Performance Monitor plugin provides comprehensive real-time performance moni The Performance Monitor plugin is a powerful debugging tool that helps you monitor and analyze performance metrics in your React Native application. It provides: +- **Startup Insights**: At-a-glance breakdown of Native Launch, JS Bundle, and Initial Mount durations with proportional bars — no extra instrumentation required - **Real-time Performance Monitoring**: Live tracking of performance marks, measures, and metrics - **Session Management**: Start/stop monitoring sessions with real-time duration tracking - **Performance Measures**: Track custom performance measurements with details @@ -47,6 +48,19 @@ With [Rozenite for Web](/docs/rozenite-for-web), this plugin is available when y Once configured, the Performance Monitor plugin will automatically appear in your React Native DevTools sidebar as "Performance Monitor". Click on it to access: +## Startup Insights + +The **Startup** tab (shown first) gives you an immediate breakdown of your app's launch time: + +- **Total startup** — the full duration from native process start to the first React render +- **Native Launch** — time spent in native initialization before the JS engine starts +- **JS Bundle** — time to parse and execute the JavaScript bundle +- **Initial Mount** — time for the first React component tree to render + +The data comes from React Native's built-in buffered marks (`nativeLaunchStart/End`, `runJSBundleStart/End`, `initialMountStart/End`), so the Startup tab populates automatically as soon as you start a session — no extra instrumentation needed in your app. + +Phases that are still running show as "In progress…". Phases absent from the event stream (e.g. `runJSBundle` on some New Architecture configurations) show as "—". + ### Performance Monitoring The plugin is based on the `react-native-performance` library and works with React Native's Performance API. Any marks, measures, or metrics you emit using the `react-native-performance` library will automatically appear in the DevTools interface. You can add custom performance marks and measures: