From 84b60dea71506018b2644f9b11a79910f08f3521 Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Thu, 21 May 2026 15:20:20 +0200 Subject: [PATCH] feat(performance-monitor-plugin): add waterfall timeline view --- .../performance-monitor-plugin/src/ui/App.css | 410 +++++++++++++ .../performance-monitor-plugin/src/ui/App.tsx | 212 ++++--- .../src/ui/__tests__/waterfall.test.ts | 192 ++++++ .../src/ui/components/DetailsSidebar.tsx | 34 +- .../src/ui/components/WaterfallView.tsx | 285 +++++++++ .../src/ui/waterfall.ts | 576 ++++++++++++++++++ 6 files changed, 1592 insertions(+), 117 deletions(-) create mode 100644 packages/performance-monitor-plugin/src/ui/__tests__/waterfall.test.ts create mode 100644 packages/performance-monitor-plugin/src/ui/components/WaterfallView.tsx create mode 100644 packages/performance-monitor-plugin/src/ui/waterfall.ts diff --git a/packages/performance-monitor-plugin/src/ui/App.css b/packages/performance-monitor-plugin/src/ui/App.css index ce9a690a..1a475e7b 100644 --- a/packages/performance-monitor-plugin/src/ui/App.css +++ b/packages/performance-monitor-plugin/src/ui/App.css @@ -95,3 +95,413 @@ color: hsl(0 0% 63.9%); font-size: 11px; } + +.details-sidebar-backdrop { + display: none; +} + +.details-sidebar { + width: min(38vw, 520px); + min-width: 360px; + height: 100%; + flex-shrink: 0; + border-left: 1px solid #333333; + background-color: #1a1a1a; + box-shadow: -2px 0 6px rgba(0, 0, 0, 0.18); +} + +/* Waterfall View */ +.waterfall-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + background: hsl(0 0% 3.9%); + color: hsl(0 0% 98%); + font-size: 12px; +} + +.waterfall-topbar { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 36px; + padding: 8px 12px; + border-bottom: 1px solid hsl(0 0% 14.9%); + color: hsl(0 0% 63.9%); +} + +.waterfall-summary, +.waterfall-legend { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.waterfall-summary strong { + color: hsl(0 0% 98%); +} + +.waterfall-legend { + flex-wrap: wrap; + justify-content: flex-end; + font-size: 11px; +} + +.waterfall-legend span { + position: relative; + padding-left: 12px; +} + +.waterfall-legend span::before { + position: absolute; + top: 50%; + left: 0; + width: 7px; + height: 7px; + border-radius: 1px; + content: ''; + transform: translateY(-50%); +} + +.waterfall-legend-measure::before { + background: #4f8cff; +} + +.waterfall-legend-resource::before { + background: #60a5fa; +} + +.waterfall-legend-mark::before { + background: #f59e0b; +} + +.waterfall-legend-metric::before { + background: #a78bfa; +} + +.waterfall-legend-react-native-mark::before { + background: #22c55e; +} + +.waterfall-overview { + position: relative; + flex-shrink: 0; + height: 52px; + margin-left: 280px; + border-bottom: 1px solid hsl(0 0% 14.9%); + background: + linear-gradient(to bottom, rgba(255, 255, 255, 0.045), transparent 50%), + hsl(0 0% 5.8%); +} + +.waterfall-overview-bar { + position: absolute; + min-width: 2px; + height: 6px; + border-radius: 1px; + opacity: 0.9; +} + +.waterfall-ruler { + display: grid; + grid-template-columns: 280px minmax(420px, 1fr); + flex-shrink: 0; + min-height: 30px; + border-bottom: 1px solid hsl(0 0% 14.9%); + color: hsl(0 0% 63.9%); + font-size: 11px; +} + +.waterfall-label-header { + display: flex; + align-items: center; + padding: 0 12px; + border-right: 1px solid hsl(0 0% 12%); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.waterfall-ruler-track { + position: relative; + overflow: hidden; +} + +.waterfall-tick-label { + position: absolute; + top: 8px; + transform: translateX(-50%); + color: hsl(0 0% 63.9%); + font-size: 10px; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; +} + +.waterfall-tick-label:first-child { + transform: translateX(0); +} + +.waterfall-tick-label:last-child { + transform: translateX(-100%); +} + +.waterfall-container { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.waterfall-virtual-list { + height: 100%; +} + +.waterfall-row { + display: grid; + grid-template-columns: 280px minmax(420px, 1fr); + min-height: 34px; + border-bottom: 1px solid hsl(0 0% 13%); + cursor: pointer; + outline: none; +} + +.waterfall-row:hover, +.waterfall-row:focus-visible { + background: hsl(0 0% 12%); +} + +.waterfall-row-selected { + background: rgba(45, 114, 210, 0.22); +} + +.waterfall-row-selected:hover, +.waterfall-row-selected:focus-visible { + background: rgba(45, 114, 210, 0.3); +} + +.waterfall-row-label { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + min-width: 0; + padding: 6px 10px 6px 0; + border-right: 1px solid hsl(0 0% 12%); +} + +.waterfall-row-index { + grid-row: 1 / 3; + color: hsl(0 0% 63.9%); + font-weight: 600; + text-align: center; +} + +.waterfall-row-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.waterfall-row-text strong, +.waterfall-row-text small { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.waterfall-row-text strong { + color: hsl(0 0% 92%); + font-weight: 600; +} + +.waterfall-row-text small { + color: hsl(0 0% 46%); + font-size: 10px; +} + +.waterfall-row-meta { + display: flex; + min-width: 0; + gap: 8px; + overflow: hidden; + color: hsl(0 0% 58%); + font-size: 10px; + white-space: nowrap; +} + +.waterfall-row-meta span { + overflow: hidden; + text-overflow: ellipsis; +} + +.waterfall-lane { + position: relative; + min-width: 420px; + overflow: hidden; +} + +.waterfall-grid-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(255, 255, 255, 0.08); + pointer-events: none; +} + +.waterfall-gap-break { + position: absolute; + top: 6px; + bottom: 6px; + min-width: 10px; + border-inline: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.025); + pointer-events: none; +} + +.waterfall-gap-break::before, +.waterfall-gap-break::after { + position: absolute; + top: 50%; + width: 10px; + height: 1px; + background: rgba(255, 255, 255, 0.32); + content: ''; +} + +.waterfall-gap-break::before { + left: 6px; + transform: rotate(-55deg); +} + +.waterfall-gap-break::after { + right: 6px; + transform: rotate(-55deg); +} + +.waterfall-selected-guide { + position: absolute; + top: 0; + bottom: 0; + z-index: 3; + width: 1px; + background: rgba(147, 197, 253, 0.88); + pointer-events: none; +} + +.waterfall-selected-duration { + position: absolute; + top: 50%; + z-index: 5; + margin-left: 6px; + color: hsl(0 0% 92%); + font-size: 10px; + line-height: 1; + text-shadow: 0 1px 2px #000; + transform: translateY(-50%); + pointer-events: none; +} + +.waterfall-bar { + position: absolute; + top: 50%; + z-index: 4; + min-width: 8px; + height: 14px; + border-radius: 2px; + overflow: hidden; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.22), + 0 1px 4px rgba(0, 0, 0, 0.35); + transform: translateY(-50%); +} + +.waterfall-bar-instant { + width: 3px !important; + min-width: 3px; +} + +.waterfall-bar-measure { + background: #4f8cff; +} + +.waterfall-bar-mark { + background: #f59e0b; +} + +.waterfall-bar-metric { + background: #a78bfa; +} + +.waterfall-bar-react-native-mark { + background: #22c55e; +} + +.waterfall-bar-resource { + background: rgba(59, 130, 246, 0.42); +} + +.waterfall-phase { + position: absolute; + top: 0; + bottom: 0; + min-width: 1px; +} + +.waterfall-phase-redirect { + background: #9ca3af; +} + +.waterfall-phase-worker { + background: #14b8a6; +} + +.waterfall-phase-dns { + background: #34d399; +} + +.waterfall-phase-connect { + background: #f97316; +} + +.waterfall-phase-tls { + background: #c084fc; +} + +.waterfall-phase-request { + background: #facc15; +} + +.waterfall-phase-response { + background: #60a5fa; +} + +@media (max-width: 1120px) { + .details-sidebar-backdrop { + position: fixed; + inset: 0; + z-index: 999; + display: block; + background-color: rgba(0, 0, 0, 0.5); + } + + .details-sidebar { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 1000; + width: min(420px, calc(100vw - 24px)); + min-width: 0; + height: 100vh; + box-shadow: -4px 0 8px rgba(0, 0, 0, 0.3); + } +} diff --git a/packages/performance-monitor-plugin/src/ui/App.tsx b/packages/performance-monitor-plugin/src/ui/App.tsx index e5078833..3ab74c63 100644 --- a/packages/performance-monitor-plugin/src/ui/App.tsx +++ b/packages/performance-monitor-plugin/src/ui/App.tsx @@ -28,6 +28,7 @@ import { MetricsTable } from './components/MetricsTable'; import { MarksTable } from './components/MarksTable'; import { ReactNativeMarksTable } from './components/ReactNativeMarksTable'; import { ResourcesTable } from './components/ResourcesTable'; +import { WaterfallView } from './components/WaterfallView'; import { DetailsSidebar } from './components/DetailsSidebar'; import { SessionDuration } from './components/SessionDuration'; import { ExportModal } from './components/ExportModal'; @@ -59,6 +60,9 @@ export default function PerformanceMonitorPanel() { const [isSessionActive, setIsSessionActive] = useState(false); const [selectedItem, setSelectedItem] = useState(null); + const [selectedWaterfallRowId, setSelectedWaterfallRowId] = useState< + string | null + >(null); useEffect(() => { if (!client) { @@ -179,12 +183,17 @@ export default function PerformanceMonitorPanel() { } }; - const handleEntryClick = (entry: SerializedPerformanceEntry) => { + const handleEntryClick = ( + entry: SerializedPerformanceEntry, + waterfallRowId?: string, + ) => { setSelectedItem(entry); + setSelectedWaterfallRowId(waterfallRowId ?? null); }; const handleCloseSidebar = () => { setSelectedItem(null); + setSelectedWaterfallRowId(null); }; // Derived measures live only in the UI: paired Start/End RN marks @@ -193,6 +202,13 @@ export default function PerformanceMonitorPanel() { // export still emits raw session data (see ExportModal below). const startupPhases = deriveStartupPhases(session.reactNativeMarks); const allMeasures = [...startupPhases, ...session.measures]; + const waterfallEntries: SerializedPerformanceEntry[] = [ + ...allMeasures, + ...session.metrics, + ...session.marks, + ...session.reactNativeMarks, + ...session.resources, + ]; return ( @@ -254,113 +270,135 @@ export default function PerformanceMonitorPanel() { - {/* Tabs */} + {/* Tabs and details */} - - - - Measures ({allMeasures.length}) - - - Metrics ({session.metrics.length}) - - - Marks ({session.marks.length}) - - - React Native Marks ({session.reactNativeMarks.length}) - - - Resources ({session.resources.length}) - - - - + - - - + + + Waterfall ({waterfallEntries.length}) + + + Measures ({allMeasures.length}) + + + Metrics ({session.metrics.length}) + + + Marks ({session.marks.length}) + + + React Native Marks ({session.reactNativeMarks.length}) + + + Resources ({session.resources.length}) + + - - - + + + - - - + + + - - - + + + - - - - - + + + + + + + + + + + + + + + + - - ); } diff --git a/packages/performance-monitor-plugin/src/ui/__tests__/waterfall.test.ts b/packages/performance-monitor-plugin/src/ui/__tests__/waterfall.test.ts new file mode 100644 index 00000000..704efd7d --- /dev/null +++ b/packages/performance-monitor-plugin/src/ui/__tests__/waterfall.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import type { + SerializedPerformanceMeasure, + SerializedPerformanceResource, +} from '../../shared/types'; +import { + buildWaterfallModel, + formatTimelineTime, + getResourcePhaseSegments, + isSamePerformanceEntry, +} from '../waterfall'; + +const measure = ( + name: string, + startTime: number, + duration: number, +): SerializedPerformanceMeasure => ({ + name, + startTime, + duration, + entryType: 'measure', +}); + +const resource = ( + overrides: Partial = {}, +): SerializedPerformanceResource => ({ + name: 'https://example.com/api/users', + startTime: 1_000, + duration: 300, + entryType: 'resource', + initiatorType: 'fetch', + transferSize: 2048, + encodedBodySize: 1024, + decodedBodySize: 4096, + fetchStart: 100, + requestStart: 160, + responseStart: 280, + responseEnd: 400, + connectStart: 130, + connectEnd: 155, + domainLookupStart: 110, + domainLookupEnd: 125, + redirectStart: 0, + redirectEnd: 0, + secureConnectionStart: 145, + workerStart: 0, + serverTiming: [], + workerTiming: [], + ...overrides, +}); + +describe('buildWaterfallModel', () => { + it('sorts entries chronologically and computes offsets across the session', () => { + const model = buildWaterfallModel([ + measure('later', 1_200, 300), + measure('first', 1_000, 100), + ]); + + expect(model.rows.map((row) => row.entry.name)).toEqual(['first', 'later']); + expect(model.startTime).toBe(1_000); + expect(model.duration).toBe(500); + expect(model.rows[0].offsetPercent).toBe(0); + expect(model.rows[1].offsetPercent).toBe(40); + expect(model.rows[1].widthPercent).toBe(60); + }); + + it('keeps instant entries visible with a zero model duration fallback', () => { + const model = buildWaterfallModel([measure('instant', 1_000, 0)]); + + expect(model.duration).toBe(1); + expect(model.rows[0].duration).toBe(0); + expect(model.rows[0].widthPercent).toBe(0); + }); + + it('compresses long idle gaps so short later events stay readable', () => { + const model = buildWaterfallModel([ + measure('startup', 1_000, 500), + measure('request', 61_000, 250), + ]); + + expect(model.hasCompressedGaps).toBe(true); + expect(model.gaps).toHaveLength(1); + expect(model.gaps[0].duration).toBe(59_500); + expect(model.rows[1].startOffset).toBe(60_000); + expect(model.rows[1].visualStartOffset).toBeLessThan( + model.rows[1].startOffset, + ); + expect(model.rows[1].widthPercent).toBeGreaterThan(5); + }); + + it('formats ruler ticks as millisecond labels', () => { + const model = buildWaterfallModel([measure('startup', 1_000, 2_000)]); + + expect(model.ticks.map((tick) => tick.label)).toEqual([ + '0 ms', + '500 ms', + '1,000 ms', + '1,500 ms', + '2,000 ms', + ]); + }); + + it('uses normalized labels on the compressed timeline', () => { + const model = buildWaterfallModel([ + measure('startup', 1_000, 500), + measure('request', 61_000, 250), + ]); + + const labels = model.ticks.map((tick) => tick.label); + expect(labels.filter((label) => label === '0 ms')).toHaveLength(1); + expect(labels).not.toContain('60,000 ms'); + expect(model.timelineDuration).toBeLessThan(model.duration); + }); +}); + +describe('formatTimelineTime', () => { + it('keeps compact millisecond labels up to one second', () => { + expect(formatTimelineTime(500)).toBe('500 ms'); + expect(formatTimelineTime(1000)).toBe('1,000 ms'); + }); + + it('keeps larger values in milliseconds', () => { + expect(formatTimelineTime(1500)).toBe('1,500 ms'); + expect(formatTimelineTime(90_000)).toBe('90,000 ms'); + expect(formatTimelineTime(774_990_543)).toBe('774,990,543 ms'); + }); +}); + +describe('getResourcePhaseSegments', () => { + it('projects resource phases relative to fetchStart', () => { + const phases = getResourcePhaseSegments(resource()); + + expect(phases.map((phase) => phase.label)).toEqual([ + 'DNS', + 'Connect', + 'TLS', + 'Request / waiting', + 'Response', + ]); + const dns = phases.find((phase) => phase.label === 'DNS'); + expect(dns?.startPercent).toBeCloseTo(3.333, 3); + expect(dns?.widthPercent).toBeCloseTo(5, 3); + }); + + it('uses inferred resource start so worker and redirect phases stay aligned', () => { + const phases = getResourcePhaseSegments( + resource({ + duration: 350, + workerStart: 50, + redirectStart: 70, + redirectEnd: 90, + fetchStart: 100, + responseEnd: 400, + }), + ); + + expect(phases.map((phase) => phase.label)).toEqual([ + 'Worker', + 'Redirect', + 'DNS', + 'Connect', + 'TLS', + 'Request / waiting', + 'Response', + ]); + const worker = phases.find((phase) => phase.label === 'Worker'); + const redirect = phases.find((phase) => phase.label === 'Redirect'); + expect(worker?.startPercent).toBe(0); + expect(worker?.widthPercent).toBeCloseTo(14.286, 3); + expect(redirect?.startPercent).toBeCloseTo(5.714, 3); + expect(redirect?.widthPercent).toBeCloseTo(5.714, 3); + }); +}); + +describe('isSamePerformanceEntry', () => { + it('matches equivalent entries after derived entries are recreated', () => { + expect( + isSamePerformanceEntry(measure('nativeLaunch', 10, 20), { + ...measure('nativeLaunch', 10, 20), + }), + ).toBe(true); + }); + + it('does not match different entry timing', () => { + expect( + isSamePerformanceEntry( + measure('nativeLaunch', 10, 20), + measure('nativeLaunch', 11, 20), + ), + ).toBe(false); + }); +}); diff --git a/packages/performance-monitor-plugin/src/ui/components/DetailsSidebar.tsx b/packages/performance-monitor-plugin/src/ui/components/DetailsSidebar.tsx index 30f1505a..92b3e6d0 100644 --- a/packages/performance-monitor-plugin/src/ui/components/DetailsSidebar.tsx +++ b/packages/performance-monitor-plugin/src/ui/components/DetailsSidebar.tsx @@ -41,36 +41,9 @@ export const DetailsSidebar = ({ return ( <> - {/* Backdrop */} - - - {/* Sidebar */} - - + + + diff --git a/packages/performance-monitor-plugin/src/ui/components/WaterfallView.tsx b/packages/performance-monitor-plugin/src/ui/components/WaterfallView.tsx new file mode 100644 index 00000000..a3c2275a --- /dev/null +++ b/packages/performance-monitor-plugin/src/ui/components/WaterfallView.tsx @@ -0,0 +1,285 @@ +import { useMemo, type KeyboardEvent } from 'react'; +import { Box, Text } from '@radix-ui/themes'; +import { Virtuoso } from 'react-virtuoso'; +import type { SerializedPerformanceEntry } from '../../shared/types'; +import { formatDuration, formatTime } from '../utils'; +import { + buildWaterfallModel, + formatTimelineTime, + isSamePerformanceEntry, + type WaterfallRow, +} from '../waterfall'; + +export type WaterfallViewProps = { + entries: SerializedPerformanceEntry[]; + selectedEntry: SerializedPerformanceEntry | null; + selectedEntryId?: string | null; + onEntrySelect: (entry: SerializedPerformanceEntry, entryId?: string) => void; +}; + +const BAR_CLASS_BY_TYPE: Record< + SerializedPerformanceEntry['entryType'], + string +> = { + measure: 'waterfall-bar-measure', + mark: 'waterfall-bar-mark', + metric: 'waterfall-bar-metric', + 'react-native-mark': 'waterfall-bar-react-native-mark', + resource: 'waterfall-bar-resource', +}; + +const getDisplayName = (entry: SerializedPerformanceEntry) => { + if (entry.entryType !== 'resource') { + return entry.name; + } + + try { + const url = new URL(entry.name); + const pathParts = url.pathname.split('/').filter(Boolean); + return pathParts.at(-1) || url.hostname; + } catch { + return entry.name; + } +}; + +const getDomain = (entry: SerializedPerformanceEntry) => { + if (entry.entryType !== 'resource') { + return null; + } + + try { + return new URL(entry.name).host; + } catch { + return null; + } +}; + +const TimelineTicks = ({ + model, + showGaps = false, +}: { + model: ReturnType; + showGaps?: boolean; +}) => { + return ( + <> + {showGaps && + model.gaps.map((gap) => ( + + ))} + {model.ticks.map((tick) => ( + + ))} + + ); +}; + +const WaterfallBar = ({ row }: { row: WaterfallRow }) => { + const isInstant = row.duration === 0; + + return ( +
+ {row.phases.map((phase) => ( + + ))} +
+ ); +}; + +const isRowSelected = ( + row: WaterfallRow, + selectedEntry: SerializedPerformanceEntry | null, + selectedEntryId?: string | null, +) => { + if (selectedEntryId) { + return selectedEntryId === row.id; + } + + return isSamePerformanceEntry(selectedEntry, row.entry); +}; + +export const WaterfallView = ({ + entries, + selectedEntry, + selectedEntryId = null, + onEntrySelect, +}: WaterfallViewProps) => { + const model = useMemo(() => buildWaterfallModel(entries), [entries]); + + const handleKeyDown = ( + event: KeyboardEvent, + row: WaterfallRow, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onEntrySelect(row.entry, row.id); + } + }; + + if (model.rows.length === 0) { + return ( + + + No performance entries recorded + + + ); + } + + return ( +
+
+
+ {model.rows.length} events + {formatTimelineTime(model.timelineDuration)} timeline + {model.hasCompressedGaps && gaps normalized} +
+ +
+ +
+ + {model.rows.map((row, index) => ( + + ))} +
+ +
+
Events
+
+ {model.ticks.map((tick) => ( + + {tick.label} + + ))} +
+
+ +
+ row.id} + itemContent={(_, row) => { + const isSelected = isRowSelected( + row, + selectedEntry, + selectedEntryId, + ); + const domain = getDomain(row.entry); + + return ( +
onEntrySelect(row.entry, row.id)} + onKeyDown={(event) => handleKeyDown(event, row)} + > +
+ {row.index + 1} + + + {getDisplayName(row.entry)} + + {domain && {domain}} + + + {row.typeLabel} + + +{formatTimelineTime(row.visualStartOffset)} + + {formatDuration(row.duration)} + {row.valueLabel && {row.valueLabel}} + +
+ +
+ + {isSelected && ( + + )} + + {isSelected && ( + + {formatDuration(row.duration)} + + )} +
+
+ ); + }} + /> +
+
+ ); +}; diff --git a/packages/performance-monitor-plugin/src/ui/waterfall.ts b/packages/performance-monitor-plugin/src/ui/waterfall.ts new file mode 100644 index 00000000..95bd753f --- /dev/null +++ b/packages/performance-monitor-plugin/src/ui/waterfall.ts @@ -0,0 +1,576 @@ +import type { + SerializedPerformanceEntry, + SerializedPerformanceMetric, + SerializedPerformanceResource, +} from '../shared/types'; +import { formatBytes, formatDuration } from './utils'; + +export type WaterfallPhaseSegment = { + label: string; + startPercent: number; + widthPercent: number; + className: string; +}; + +export type WaterfallRow = { + id: string; + entry: SerializedPerformanceEntry; + index: number; + typeLabel: string; + startOffset: number; + visualStartOffset: number; + duration: number; + offsetPercent: number; + widthPercent: number; + valueLabel: string | null; + phases: WaterfallPhaseSegment[]; +}; + +export type WaterfallTick = { + label: string; + offsetPercent: number; + title: string; + elapsedTime: number; + visualTime: number; +}; + +export type WaterfallGap = { + id: string; + label: string; + startOffset: number; + duration: number; + offsetPercent: number; + widthPercent: number; +}; + +export type WaterfallModel = { + rows: WaterfallRow[]; + ticks: WaterfallTick[]; + gaps: WaterfallGap[]; + startTime: number; + duration: number; + timelineDuration: number; + hasCompressedGaps: boolean; +}; + +const MIN_TIMELINE_DURATION = 1; +const MIN_TICK_SPACING_PERCENT = 6; + +const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); +const clampRatio = (value: number) => Math.min(1, Math.max(0, value)); + +const isFiniteNumber = (value: number) => Number.isFinite(value); + +const median = (values: number[]) => { + if (values.length === 0) { + return 0; + } + + const sorted = values.slice().sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return (sorted[middle - 1] + sorted[middle]) / 2; + } + + return sorted[middle]; +}; + +export const formatTimelineTime = (time: number) => { + return `${Math.round(time).toLocaleString('en-US')} ms`; +}; + +const getNiceTickStep = (duration: number) => { + const targetTicks = 5; + const roughStep = Math.max(1, duration / targetTicks); + const magnitude = 10 ** Math.floor(Math.log10(roughStep)); + const normalized = roughStep / magnitude; + + if (normalized <= 1) { + return magnitude; + } + + if (normalized <= 2) { + return 2 * magnitude; + } + + if (normalized <= 5) { + return 5 * magnitude; + } + + return 10 * magnitude; +}; + +export const getEntryTypeLabel = (entry: SerializedPerformanceEntry) => { + switch (entry.entryType) { + case 'measure': + return 'Measure'; + case 'mark': + return 'Mark'; + case 'metric': + return 'Metric'; + case 'react-native-mark': + return 'React Native'; + case 'resource': + return entry.initiatorType ?? 'Resource'; + } +}; + +export const getEntryValueLabel = (entry: SerializedPerformanceEntry) => { + if (entry.entryType === 'resource') { + return formatBytes(entry.transferSize); + } + + if (entry.entryType === 'metric') { + return String((entry as SerializedPerformanceMetric).value); + } + + return null; +}; + +export const getEntryKey = ( + entry: SerializedPerformanceEntry, + index?: number, +) => { + const suffix = index === undefined ? '' : `:${index}`; + return ( + [ + entry.entryType, + entry.name, + entry.startTime, + entry.duration, + entry.entryType === 'metric' ? entry.value : '', + entry.entryType === 'resource' ? entry.responseEnd : '', + ].join(':') + suffix + ); +}; + +export const isSamePerformanceEntry = ( + left: SerializedPerformanceEntry | null, + right: SerializedPerformanceEntry | null, +) => { + if (!left || !right) { + return false; + } + + return getEntryKey(left) === getEntryKey(right); +}; + +const createPhaseSegment = ( + label: string, + start: number, + end: number, + baseline: number, + duration: number, + className: string, +): WaterfallPhaseSegment | null => { + if ( + !isFiniteNumber(start) || + !isFiniteNumber(end) || + end <= start || + end <= baseline || + duration <= 0 + ) { + return null; + } + + const startPercent = clampPercent(((start - baseline) / duration) * 100); + const endPercent = clampPercent(((end - baseline) / duration) * 100); + const widthPercent = Math.max(0, endPercent - startPercent); + + if (widthPercent === 0) { + return null; + } + + return { + label, + startPercent, + widthPercent, + className, + }; +}; + +const getPositiveTimingValue = (value: number | undefined) => { + if (value === undefined || !isFiniteNumber(value) || value <= 0) { + return null; + } + + return value; +}; + +const getResourceTimingBaseline = (resource: SerializedPerformanceResource) => { + const inferredStart = + resource.duration > 0 && resource.responseEnd > 0 + ? resource.responseEnd - resource.duration + : null; + const candidates = [ + inferredStart, + getPositiveTimingValue(resource.workerStart), + getPositiveTimingValue(resource.redirectStart), + getPositiveTimingValue(resource.fetchStart), + getPositiveTimingValue(resource.domainLookupStart), + getPositiveTimingValue(resource.connectStart), + getPositiveTimingValue(resource.requestStart), + getPositiveTimingValue(resource.responseStart), + ].filter( + (value): value is number => + value !== null && isFiniteNumber(value) && value >= 0, + ); + + if (candidates.length > 0) { + return Math.min(...candidates); + } + + return 0; +}; + +const getResourceDuration = (resource: SerializedPerformanceResource) => { + if (resource.duration > 0) { + return resource.duration; + } + + const baseline = getResourceTimingBaseline(resource); + + return Math.max(0, resource.responseEnd - baseline); +}; + +export const getResourcePhaseSegments = ( + resource: SerializedPerformanceResource, +): WaterfallPhaseSegment[] => { + const baseline = getResourceTimingBaseline(resource); + const duration = getResourceDuration(resource); + const workerStart = getPositiveTimingValue(resource.workerStart); + const secureConnectionStart = getPositiveTimingValue( + resource.secureConnectionStart, + ); + const connectPhaseEnd = + secureConnectionStart !== null && + secureConnectionStart > resource.connectStart && + secureConnectionStart < resource.connectEnd + ? secureConnectionStart + : resource.connectEnd; + + const segments = [ + workerStart === null + ? null + : createPhaseSegment( + 'Worker', + workerStart, + resource.fetchStart, + baseline, + duration, + 'waterfall-phase-worker', + ), + createPhaseSegment( + 'Redirect', + resource.redirectStart, + resource.redirectEnd, + baseline, + duration, + 'waterfall-phase-redirect', + ), + createPhaseSegment( + 'DNS', + resource.domainLookupStart, + resource.domainLookupEnd, + baseline, + duration, + 'waterfall-phase-dns', + ), + createPhaseSegment( + 'Connect', + resource.connectStart, + connectPhaseEnd, + baseline, + duration, + 'waterfall-phase-connect', + ), + secureConnectionStart === null + ? null + : createPhaseSegment( + 'TLS', + secureConnectionStart, + resource.connectEnd, + baseline, + duration, + 'waterfall-phase-tls', + ), + createPhaseSegment( + 'Request / waiting', + resource.requestStart, + resource.responseStart, + baseline, + duration, + 'waterfall-phase-request', + ), + createPhaseSegment( + 'Response', + resource.responseStart, + resource.responseEnd, + baseline, + duration, + 'waterfall-phase-response', + ), + ]; + + return segments.filter((segment): segment is WaterfallPhaseSegment => + Boolean(segment), + ); +}; + +const getEntryEndTime = (entry: SerializedPerformanceEntry) => { + if (entry.entryType === 'resource') { + return entry.startTime + getResourceDuration(entry); + } + + return entry.startTime + Math.max(0, entry.duration); +}; + +type WaterfallCluster = { + start: number; + end: number; + visualStart: number; + visualDuration: number; +}; + +type WaterfallScale = { + clusters: WaterfallCluster[]; + gaps: Array<{ + start: number; + end: number; + visualStart: number; + visualDuration: number; + }>; + visualDuration: number; + gapThreshold: number; +}; + +const createScale = ( + sortedEntries: SerializedPerformanceEntry[], +): WaterfallScale => { + const positiveDurations = sortedEntries + .map((entry) => getEntryEndTime(entry) - entry.startTime) + .filter((duration) => duration > 0); + const typicalDuration = median(positiveDurations); + const gapThreshold = Math.max(1000, typicalDuration * 8); + const compressedGapDuration = Math.max(250, Math.min(1500, gapThreshold / 2)); + const clusters: Array< + Omit + > = []; + + for (const entry of sortedEntries) { + const entryStart = entry.startTime; + const entryEnd = getEntryEndTime(entry); + const previousCluster = clusters.at(-1); + + if (!previousCluster) { + clusters.push({ start: entryStart, end: entryEnd }); + continue; + } + + const gap = entryStart - previousCluster.end; + + if (gap > gapThreshold) { + clusters.push({ start: entryStart, end: entryEnd }); + continue; + } + + previousCluster.end = Math.max(previousCluster.end, entryEnd); + } + + const visualClusters: WaterfallCluster[] = []; + const gaps: WaterfallScale['gaps'] = []; + let visualCursor = 0; + + clusters.forEach((cluster, index) => { + const visualDuration = Math.max( + MIN_TIMELINE_DURATION, + cluster.end - cluster.start, + ); + + visualClusters.push({ + ...cluster, + visualStart: visualCursor, + visualDuration, + }); + + visualCursor += visualDuration; + + const nextCluster = clusters[index + 1]; + if (nextCluster) { + gaps.push({ + start: cluster.end, + end: nextCluster.start, + visualStart: visualCursor, + visualDuration: compressedGapDuration, + }); + visualCursor += compressedGapDuration; + } + }); + + return { + clusters: visualClusters, + gaps, + visualDuration: Math.max(MIN_TIMELINE_DURATION, visualCursor), + gapThreshold, + }; +}; + +const getVisualTime = (time: number, scale: WaterfallScale) => { + const firstCluster = scale.clusters[0]; + + if (!firstCluster) { + return 0; + } + + for (const cluster of scale.clusters) { + if (time <= cluster.end) { + return ( + cluster.visualStart + + clampRatio((time - cluster.start) / cluster.visualDuration) * + cluster.visualDuration + ); + } + + if (time < cluster.start) { + return cluster.visualStart; + } + } + + const lastCluster = scale.clusters[scale.clusters.length - 1]; + return lastCluster.visualStart + lastCluster.visualDuration; +}; + +const createTicks = (scale: WaterfallScale): WaterfallTick[] => { + const ticks: WaterfallTick[] = []; + const seenOffsets = new Set(); + + const addTick = (visualTime: number) => { + const offsetPercent = clampPercent( + (visualTime / scale.visualDuration) * 100, + ); + const roundedOffset = Math.round(visualTime); + const lastTick = ticks.at(-1); + + if (seenOffsets.has(roundedOffset)) { + return; + } + + if ( + lastTick && + offsetPercent - lastTick.offsetPercent < MIN_TICK_SPACING_PERCENT + ) { + return; + } + + seenOffsets.add(roundedOffset); + ticks.push({ + label: formatTimelineTime(visualTime), + offsetPercent, + title: `${formatTimelineTime(visualTime)} on the normalized timeline`, + elapsedTime: visualTime, + visualTime, + }); + }; + + for (const cluster of scale.clusters) { + const step = getNiceTickStep(cluster.end - cluster.start); + const tickCount = Math.max( + 1, + Math.floor((cluster.end - cluster.start) / step), + ); + + for (let index = 0; index <= tickCount; index += 1) { + const localTime = Math.min(index * step, cluster.end - cluster.start); + const visualTime = cluster.visualStart + localTime; + + addTick(visualTime); + } + } + + return ticks; +}; + +export const buildWaterfallModel = ( + entries: SerializedPerformanceEntry[], +): WaterfallModel => { + const sortedEntries = entries + .filter((entry) => isFiniteNumber(entry.startTime)) + .slice() + .sort((left, right) => { + if (left.startTime !== right.startTime) { + return left.startTime - right.startTime; + } + + return getEntryEndTime(right) - getEntryEndTime(left); + }); + + if (sortedEntries.length === 0) { + return { + rows: [], + ticks: [], + gaps: [], + startTime: 0, + duration: MIN_TIMELINE_DURATION, + timelineDuration: MIN_TIMELINE_DURATION, + hasCompressedGaps: false, + }; + } + + const startTime = sortedEntries[0].startTime; + const endTime = Math.max(...sortedEntries.map(getEntryEndTime)); + const duration = Math.max(MIN_TIMELINE_DURATION, endTime - startTime); + const scale = createScale(sortedEntries); + + return { + rows: sortedEntries.map((entry, index) => { + const entryDuration = Math.max( + 0, + getEntryEndTime(entry) - entry.startTime, + ); + const startOffset = entry.startTime - startTime; + const visualStart = getVisualTime(entry.startTime, scale); + const visualEnd = getVisualTime(getEntryEndTime(entry), scale); + const offsetPercent = clampPercent( + (visualStart / scale.visualDuration) * 100, + ); + const widthPercent = clampPercent( + ((visualEnd - visualStart) / scale.visualDuration) * 100, + ); + + return { + id: getEntryKey(entry, index), + entry, + index, + typeLabel: getEntryTypeLabel(entry), + startOffset, + visualStartOffset: visualStart, + duration: entryDuration, + offsetPercent, + widthPercent, + valueLabel: getEntryValueLabel(entry), + phases: + entry.entryType === 'resource' ? getResourcePhaseSegments(entry) : [], + }; + }), + ticks: createTicks(scale), + gaps: scale.gaps.map((gap, index) => ({ + id: `gap:${index}:${gap.start}`, + label: `${formatDuration(gap.end - gap.start)} idle`, + startOffset: gap.start - startTime, + duration: gap.end - gap.start, + offsetPercent: clampPercent( + (gap.visualStart / scale.visualDuration) * 100, + ), + widthPercent: clampPercent( + (gap.visualDuration / scale.visualDuration) * 100, + ), + })), + startTime, + duration, + timelineDuration: scale.visualDuration, + hasCompressedGaps: scale.gaps.some( + (gap) => gap.end - gap.start > scale.gapThreshold, + ), + }; +};