Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/performance-monitor-startup-insights.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion packages/performance-monitor-plugin/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -264,10 +265,11 @@ export default function PerformanceMonitorPanel() {
}}
>
<Tabs.Root
defaultValue="measures"
defaultValue="startup"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<Tabs.List style={{ flexShrink: 0 }}>
<Tabs.Trigger value="startup">Startup</Tabs.Trigger>
<Tabs.Trigger value="measures">
Measures ({allMeasures.length})
</Tabs.Trigger>
Expand All @@ -293,6 +295,13 @@ export default function PerformanceMonitorPanel() {
minHeight: 0,
}}
>
<Tabs.Content value="startup" style={{ display: 'contents' }}>
<StartupTab
reactNativeMarks={session.reactNativeMarks}
isSessionActive={isSessionActive}
/>
</Tabs.Content>

<Tabs.Content
value="measures"
style={{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import { deriveStartupSummary } from '../derive-startup-summary';
import type { SerializedPerformanceReactNativeMark } from '../../shared/types';

const mark = (
name: string,
startTime: number,
): SerializedPerformanceReactNativeMark => ({
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,
});
});
});
});
138 changes: 138 additions & 0 deletions packages/performance-monitor-plugin/src/ui/components/StartupTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text
size="2"
color="gray"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
</Text>
);
}
if (phase.status === 'in-progress') {
return (
<Text size="2" color="gray" style={{ fontStyle: 'italic' }}>
In progress…
</Text>
);
}
return (
<Text
size="2"
color="blue"
style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}
>
{formatDuration(phase.duration!)}
</Text>
);
};

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 (
<Box
style={{
flex: 1,
height: '8px',
borderRadius: '4px',
background: BAR_TRACK_COLOR,
overflow: 'hidden',
}}
>
<Box
style={{
width: `${pct}%`,
height: '100%',
borderRadius: '4px',
background: BAR_COLOR,
}}
/>
</Box>
);
};

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 (
<Flex align="center" justify="center" style={{ flex: 1, height: '100%' }}>
<Text color="gray" size="2">
Start a session to see startup data
</Text>
</Flex>
);
}

const { phases, total } = deriveStartupSummary(reactNativeMarks);
const totalDuration =
total.status === 'complete' ? total.duration : undefined;

return (
<Box p="4" style={{ overflowY: 'auto', height: '100%' }}>
{/* Total row */}
<Flex align="center" gap="4" style={ROW_STYLE}>
<Box style={{ width: '160px', flexShrink: 0 }}>
<Text size="2" weight="bold">
Total startup
</Text>
</Box>
<Box style={{ width: '90px', flexShrink: 0 }}>
<DurationCell phase={total} />
</Box>
</Flex>

{/* Phase rows */}
{phases.map((phase) => (
<Flex key={phase.name} align="center" gap="4" style={ROW_STYLE}>
<Box style={{ width: '160px', flexShrink: 0 }}>
<Text size="2" color="gray">
{phase.label}
</Text>
</Box>
<Box style={{ width: '90px', flexShrink: 0 }}>
<DurationCell phase={phase} />
</Box>
<PhaseBar phase={phase} totalDuration={totalDuration} />
</Flex>
))}
</Box>
);
};
Loading
Loading