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
5 changes: 5 additions & 0 deletions .changeset/correct-metric-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@github-ui/storybook-addon-performance-panel': minor
---

Add accurately named metric aliases, normalized units, and exhaustive provenance and quality metadata while deprecating misleading legacy fields. Update the panel to report pointer frame intervals, initial paint milestones, script resource loading time, layer-promotion candidates, and DOM mutations per second.
27 changes: 21 additions & 6 deletions packages/storybook-addon-performance-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The addon consists of two main parts:

### Input Responsiveness
- **Input Latency**: Time from pointer event to next animation frame
- **Paint Time**: Browser rendering time via double-RAF technique
- **Pointer Frame Interval**: Time between the first and second animation frames scheduled after a pointer move (double-RAF heuristic, not paint duration)
- **INP**: Interaction to Next Paint via [Event Timing API](https://w3c.github.io/event-timing/) (Core Web Vital)
- Uses `PerformanceObserver` with `event` entry type for accurate measurement
- Calculated as p98 of worst interactions per Web Vitals spec
Expand All @@ -88,7 +88,7 @@ The addon consists of two main parts:
- **Long Tasks**: Tasks blocking main thread >50ms (via PerformanceObserver)
- **Total Blocking Time (TBT)**: Sum of (duration - 50ms) for all long tasks
- **Thrashing**: Style writes followed by long frames (forced sync layout)
- **DOM Churn**: Rate of DOM mutations per measurement period
- **DOM Churn**: Average DOM mutations normalized to a per-second rate

### Long Animation Frames (Chrome 123+)
- **LoAF Count**: Number of animation frames exceeding 50ms
Expand Down Expand Up @@ -121,7 +121,9 @@ The addon consists of two main parts:
- **Heap Usage**: Current JS heap size
- **Memory Delta**: Change from baseline since last reset
- **GC Pressure**: Memory allocation rate (MB/s)
- **Compositor Layers**: Elements promoted to GPU layers
- **Initial Paint Milestones**: Native first-paint and first-contentful-paint entries
- **Script Resource Load Time**: Cumulative loading duration derived from script Resource Timing entries
- **Layer-Promotion Candidates**: Elements matching CSS layer-promotion heuristics (not the browser's compositor layer count)

## Metric Thresholds

Expand Down Expand Up @@ -179,6 +181,19 @@ import '@github-ui/storybook-addon-performance-panel/preset'

The universal entry collects all browser-level metrics (frame timing, CLS, INP, etc.) but omits React Profiler metrics. The React Performance section is automatically hidden in the panel.

### Metric contract metadata

Every public metric has static metadata describing its source, confidence, and unit:

```typescript
import {PERFORMANCE_METRIC_METADATA} from '@github-ui/storybook-addon-performance-panel'

const {provenance, quality, unit} = PERFORMANCE_METRIC_METADATA.pointerFrameInterval
// {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}
```

Quality is `high` for direct or deterministic signals, `medium` for sampled or browser-limited values, `low` for heuristic proxies, and `unavailable` for unsupported compatibility placeholders. This metadata is invariant and is not repeated in live channel payloads.

## Collection Lifecycle

Browser performance collection runs automatically while the Performance panel is selected. Closing the panel disconnects browser collectors, DOM observers, and live-update timers to minimize background overhead. Reopening the panel resumes collection without clearing the metrics already gathered for the current story. React Profiler callbacks remain attached so mount and render history is not lost before the panel opens.
Expand All @@ -203,7 +218,7 @@ The addon uses modular collector classes for metrics gathering. Each collector u
| `LongAnimationFrameCollector` | LoAF API (`PerformanceObserver`) | **Optimal** |
| `LayoutShiftCollector` | Layout Instability API (`PerformanceObserver`) | **Optimal** |
| `MemoryCollector` | `performance.memory` | **Optimal** |
| `PaintCollector` | Paint Timing API (`PerformanceObserver`) | **Optimal** |
| `PaintCollector` | Paint + Resource Timing APIs, CSS heuristic | Mixed |
| `StyleMutationCollector` | `MutationObserver` | Heuristic |
| `ForcedReflowCollector` | Property getter instrumentation | Heuristic |
| `ReactProfilerCollector` | React Profiler API | **Optimal** |
Expand All @@ -214,7 +229,7 @@ The addon uses modular collector classes for metrics gathering. Each collector u
- **Firefox/Safari**: Most metrics supported, memory API and LoAF unavailable
- **Memory API**: Requires `performance.memory` (Chrome-only)
- **Long Animation Frames**: Requires Chrome 123+ or Edge 123+
- **Compositor Layers**: Requires Chrome DevTools Protocol
- **Layer-Promotion Candidates**: CSS heuristic available in all supported browsers; not a compositor-layer measurement

## Development

Expand Down Expand Up @@ -266,7 +281,7 @@ Start by scanning these key indicators:
**Where to Look:**
1. Check `Mount Duration` in React section
2. Look at `Long Tasks` count and `Longest Task` duration
3. Review `Script Eval Time` in Resources section
3. Review script requests in the Network panel and the derived `scriptResourceLoadTime` metric

**Common Causes:**
- Heavy component initialization
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {convert, ThemeProvider, themes} from 'storybook/theming'
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {render} from 'vitest-browser-react'

import {PERF_EVENTS} from '../core/performance-types'
import {DEFAULT_METRICS, PERF_EVENTS} from '../core/performance-types'
import {PerformancePanel} from '../performance-panel'

type ChannelEventMap = Record<string, (...args: unknown[]) => void>
Expand All @@ -11,18 +11,19 @@ const channel = vi.hoisted(() => ({
emit: vi.fn(),
registrations: [] as {events: ChannelEventMap; deps?: unknown[]}[],
}))
const storybookState = vi.hoisted(() => ({
previewInitialized: true,
refId: undefined,
storyId: undefined as string | undefined,
viewMode: 'story',
}))

vi.mock('storybook/manager-api', () => ({
useChannel: (events: ChannelEventMap, deps?: unknown[]) => {
channel.registrations.push({events, deps})
return channel.emit
},
useStorybookState: () => ({
previewInitialized: true,
refId: undefined,
storyId: undefined,
viewMode: 'story',
}),
useStorybookState: () => storybookState,
}))

function renderPanel(active: boolean) {
Expand All @@ -37,6 +38,7 @@ describe('PerformancePanel visibility', () => {
beforeEach(() => {
channel.emit.mockClear()
channel.registrations.length = 0
storybookState.storyId = undefined
})

it('reports the latest visibility after AddonPanel freezes its inactive children', async () => {
Expand Down Expand Up @@ -70,4 +72,29 @@ describe('PerformancePanel visibility', () => {

expect(channel.emit).toHaveBeenCalledWith(PERF_EVENTS.PANEL_VISIBILITY, false)
})

it('uses corrected names for heuristic and derived metrics', async () => {
storybookState.storyId = 'benchmark-story'
await render(renderPanel(true))
const metricsRegistration = channel.registrations.filter(({events}) => PERF_EVENTS.METRICS_UPDATE in events).at(-1)

metricsRegistration?.events[PERF_EVENTS.METRICS_UPDATE]?.({
...DEFAULT_METRICS,
pointerFrameInterval: 16,
maxPointerFrameInterval: 20,
domMutationsPerSecond: 25,
initialPaintMilestones: 2,
layerPromotionCandidates: 3,
})

await expect.poll(() => document.body.textContent).toContain('Pointer Frame Interval')
await expect.poll(() => document.body.textContent).toContain('DOM Churn')
await expect.poll(() => document.body.textContent).toContain('Initial Paint Milestones')
await expect.poll(() => document.body.textContent).toContain('Layer-Promotion Candidates')
Comment on lines +90 to +93
await expect.poll(() => document.body.textContent).toContain('16.0ms')
await expect.poll(() => document.body.textContent).toContain('20.0ms')
await expect.poll(() => document.body.textContent).toContain('25/s')
await expect.poll(() => document.body.textContent).toMatch(/Initial Paint Milestones[\s\S]*2/)
await expect.poll(() => document.body.textContent).toMatch(/Layer-Promotion Candidates[\s\S]*3/)
})
})
20 changes: 10 additions & 10 deletions packages/storybook-addon-performance-panel/collectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This directory contains modular metric collector classes used by the performance
| [ElementTimingCollector](#elementtimingcollector) | Element Timing API (`PerformanceObserver`) | **Optimal** | Excellent | Custom element render timing |
| [LayoutShiftCollector](#layoutshiftcollector) | Layout Instability API (`PerformanceObserver`) | **Optimal** | Excellent | Standard CLS measurement |
| [MemoryCollector](#memorycollector) | `performance.memory` | **Optimal** | Excellent | Only available API (Chrome-only) |
| [PaintCollector](#paintcollector) | Paint Timing API (`PerformanceObserver`) | **Optimal** | Good | Standard paint event tracking |
| [PaintCollector](#paintcollector) | Paint + Resource Timing APIs, CSS scan | Mixed | Good | Native milestones, derived loading time, heuristic candidates |
| [StyleMutationCollector](#stylemutationcollector) | `MutationObserver` | Heuristic | Good | Only available method for DOM tracking |
| [ForcedReflowCollector](#forcedreflowcollector) | Property getter instrumentation | Heuristic | Moderate | Approximation via property access patterns |
| [ReactProfilerCollector](#reactprofilercollector) | React Profiler API | **Optimal** | Excellent | Official React instrumentation |
Expand Down Expand Up @@ -73,8 +73,8 @@ This directory contains modular metric collector classes used by the performance
- `avgPresentationDelay` - Time from handlers to next paint
- `interactionCount` - Total discrete interactions tracked
- `inputLatencies[]` - Pointer move latencies (hover responsiveness)
- `paintTimes[]` - Paint time estimates
- `inputJitter` / `paintJitter` - Spike counts
- `paintTimes[]` - Internal name for double-RAF pointer frame intervals
- `inputJitter` / `paintJitter` - Input and pointer frame interval spike counts
- `firstInputDelay` - First Input Delay (FID) - latency of first interaction
- `firstInputType` - Event type of first input (click, keydown, etc.)
- `slowestInteraction` - Details about worst interaction for debugging:
Expand Down Expand Up @@ -399,9 +399,9 @@ export function getMemoryMB(): number | null {
**File:** [paint-collector.ts](./paint-collector.ts)

### Metrics
- `paintCount` - Total paint operations observed
- `scriptEvalTime` - Cumulative script loading time
- `compositorLayers` - Elements promoted to GPU (estimated)
- `paintCount` - Internal name for native initial paint milestones; exposed publicly as `initialPaintMilestones`
- `scriptEvalTime` - Internal name for derived script resource loading time; exposed as `scriptResourceLoadTime`
- `compositorLayers` - Internal heuristic count; exposed as `layerPromotionCandidates`

### Collection Method: Paint Timing API + Resource Timing
**Type:** Optimal ✅ (for paint/resource), Heuristic (for layers)
Expand All @@ -413,7 +413,7 @@ this.#paintObserver = new PerformanceObserver(list => {
})
this.#paintObserver.observe({type: 'paint', buffered: true})

// Resource timing for script evaluation
// Resource Timing entries for script loading duration
this.#resourceObserver = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === 'script') {
Expand All @@ -423,7 +423,7 @@ this.#resourceObserver = new PerformanceObserver(list => {
})
```

**Compositor layers estimation (heuristic):**
**Layer-promotion candidate scan (heuristic):**
```typescript
// Checks computed styles for layer-promoting properties
const style = getComputedStyle(el)
Expand All @@ -434,7 +434,7 @@ if (style.transform?.startsWith('matrix3d')) layerCount++
**Why this approach:**
- Paint Timing API is standard for first-paint/first-contentful-paint
- Resource Timing provides script load metrics
- Compositor layer detection is a heuristic (no direct API available)
- Layer-promotion candidates do not represent the browser's actual compositor layers (no direct web API exists)

---

Expand All @@ -445,7 +445,7 @@ if (style.transform?.startsWith('matrix3d')) layerCount++
### Metrics
- `styleWrites` - Inline style attribute mutations
- `cssVarChanges` - CSS custom property changes
- `domMutationFrames[]` - DOM mutations per sample period
- `domMutationFrames[]` - Internal 200ms mutation samples, normalized to `domMutationsPerSecond` in public metrics
- `thrashingScore` - Style writes near long frames

### Collection Method: MutationObserver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ describe('CollectorManager', () => {
expect(updateSpy).toHaveBeenCalled()
})

it('compositor layers are tracked automatically via MutationObserver', () => {
// Compositor layer tracking is now handled internally by PaintCollector
it('layer-promotion candidates are tracked automatically via MutationObserver', () => {
// Candidate tracking is handled internally by PaintCollector.
// via MutationObserver — no manual updateCompositorLayers() call needed
manager.updateSparklineData()
// Just verify no error is thrown
Expand Down Expand Up @@ -376,14 +376,50 @@ describe('CollectorManager', () => {
expect(metrics.slowReactUpdates).toBe(1) // 20ms > 16ms
})

it('exposes corrected metric aliases without changing deprecated values', () => {
const inputMetrics = manager.collectors.input.getMetrics()
vi.spyOn(manager.collectors.input, 'getMetrics').mockReturnValue({
...inputMetrics,
paintTimes: [8, 12],
maxPaintTime: 14,
paintJitter: 2,
})
const paintMetrics = manager.collectors.paint.getMetrics()
vi.spyOn(manager.collectors.paint, 'getMetrics').mockReturnValue({
...paintMetrics,
paintCount: 2,
scriptEvalTime: 33.3,
compositorLayers: 4,
})
const styleMetrics = manager.collectors.style.getMetrics()
vi.spyOn(manager.collectors.style, 'getMetrics').mockReturnValue({
...styleMetrics,
domMutationFrames: [2, 4],
domMutationSampleDurationsMs: [200, 400],
})

const metrics = manager.computeMetrics()
const deprecatedMetrics = metrics as unknown as Record<string, unknown>

expect(metrics.pointerFrameInterval).toBe(10)
expect(metrics.pointerFrameInterval).toBe(deprecatedMetrics.paintTime)
expect(metrics.maxPointerFrameInterval).toBe(deprecatedMetrics.maxPaintTime)
expect(metrics.pointerFrameJitter).toBe(deprecatedMetrics.paintJitter)
expect(metrics.initialPaintMilestones).toBe(deprecatedMetrics.paintCount)
expect(metrics.scriptResourceLoadTime).toBe(deprecatedMetrics.scriptEvalTime)
expect(metrics.layerPromotionCandidates).toBe(deprecatedMetrics.compositorLayers)
expect(deprecatedMetrics.domMutationsPerFrame).toBe(3)
expect(metrics.domMutationsPerSecond).toBe(10)
})

it('rounds numeric values appropriately', () => {
const metrics = manager.computeMetrics()

// These should be rounded to 1 decimal place
expect(metrics.frameTime).toBe(Math.round(metrics.frameTime * 10) / 10)
expect(metrics.maxFrameTime).toBe(Math.round(metrics.maxFrameTime * 10) / 10)
expect(metrics.inputLatency).toBe(Math.round(metrics.inputLatency * 10) / 10)
expect(metrics.paintTime).toBe(Math.round(metrics.paintTime * 10) / 10)
expect(metrics.pointerFrameInterval).toBe(Math.round(metrics.pointerFrameInterval * 10) / 10)
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe('PaintCollector', () => {
expect(metrics.paintCount).toBe(2)
})

it('accumulates paint count', () => {
it('accumulates initial paint milestones', () => {
collector.start()
const startTime = performance.now()

Expand All @@ -128,7 +128,7 @@ describe('PaintCollector', () => {
expect(metrics.paintCount).toBe(3)
})

it('tracks script evaluation time', () => {
it('tracks script resource loading time', () => {
collector.start()
const startTime = performance.now()

Expand Down Expand Up @@ -216,7 +216,7 @@ describe('PaintCollector', () => {
})
})

describe('compositor layer tracking', () => {
describe('layer-promotion candidate tracking', () => {
it('detects elements with will-change via initial scan', async () => {
const el = document.createElement('div')
el.style.willChange = 'transform'
Expand Down Expand Up @@ -251,7 +251,7 @@ describe('PaintCollector', () => {
collector.start()
await waitUntil(() => collector.getMetrics().compositorLayers !== null)

// Should have a numeric count but 2D transforms don't create compositor layers
// A 2D transform alone is not treated as a layer-promotion candidate.
expect(collector.getMetrics().compositorLayers).not.toBeNull()

document.body.removeChild(el)
Expand Down Expand Up @@ -345,7 +345,7 @@ describe('PaintCollector', () => {
expect(metrics.scriptEvalTime).toBe(0)
})

it('rescans compositor layers after reset', async () => {
it('rescans layer-promotion candidates after reset', async () => {
collector.start()
await waitUntil(() => collector.getMetrics().compositorLayers !== null)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getZeroIsGoodStatus,
PANEL_ID,
PERF_EVENTS,
PERFORMANCE_METRIC_METADATA,
THRESHOLDS,
} from '../../core/performance-types'

Expand Down Expand Up @@ -94,6 +95,14 @@ describe('THRESHOLDS', () => {
expect(THRESHOLDS.TBT_WARNING).toBe(200)
expect(THRESHOLDS.TBT_DANGER).toBe(600)
})

it('normalizes deprecated DOM mutation thresholds to a per-second rate', () => {
const deprecatedThresholds = THRESHOLDS as unknown as Record<string, number>
expect(deprecatedThresholds.DOM_MUTATIONS_WARNING).toBe(50)
expect(deprecatedThresholds.DOM_MUTATIONS_DANGER).toBe(200)
expect(THRESHOLDS.DOM_MUTATIONS_PER_SECOND_WARNING).toBe(250)
expect(THRESHOLDS.DOM_MUTATIONS_PER_SECOND_DANGER).toBe(1000)
})
})

describe('DEFAULT_METRICS', () => {
Expand All @@ -116,6 +125,39 @@ describe('DEFAULT_METRICS', () => {
expect(DEFAULT_METRICS.frameTimeHistory).toEqual([])
expect(DEFAULT_METRICS.memoryHistory).toEqual([])
})

it('initializes corrected metric aliases', () => {
const deprecatedMetrics = DEFAULT_METRICS as unknown as Record<string, unknown>
expect(DEFAULT_METRICS.pointerFrameInterval).toBe(deprecatedMetrics.paintTime)
expect(DEFAULT_METRICS.maxPointerFrameInterval).toBe(deprecatedMetrics.maxPaintTime)
expect(DEFAULT_METRICS.pointerFrameJitter).toBe(deprecatedMetrics.paintJitter)
expect(DEFAULT_METRICS.initialPaintMilestones).toBe(deprecatedMetrics.paintCount)
expect(DEFAULT_METRICS.scriptResourceLoadTime).toBe(deprecatedMetrics.scriptEvalTime)
expect(DEFAULT_METRICS.layerPromotionCandidates).toBe(deprecatedMetrics.compositorLayers)
expect(DEFAULT_METRICS.domMutationsPerSecond).toBe(0)
})
})

describe('PERFORMANCE_METRIC_METADATA', () => {
it('has metadata for every public metric', () => {
expect(new Set(Object.keys(PERFORMANCE_METRIC_METADATA))).toEqual(new Set(Object.keys(DEFAULT_METRICS)))
})

it('identifies provenance, quality, and units', () => {
expect(PERFORMANCE_METRIC_METADATA.initialPaintMilestones).toEqual({
provenance: 'native',
quality: 'high',
unit: 'count',
})
expect(PERFORMANCE_METRIC_METADATA.domMutationsPerSecond).toEqual({
provenance: 'derived',
quality: 'medium',
unit: 'per-second',
})
expect(PERFORMANCE_METRIC_METADATA.layerPromotionCandidates.quality).toBe('low')
expect(PERFORMANCE_METRIC_METADATA.eventListenerCount.quality).toBe('unavailable')
expect(PERFORMANCE_METRIC_METADATA.observerCount.quality).toBe('unavailable')
})
})

describe('Addon identifiers', () => {
Expand Down
Loading