From 891fd3facdd4fe6c020e7fb10ca0640a91c5301e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 14:13:19 +0000 Subject: [PATCH 1/2] Correct public metric contracts --- .changeset/correct-metric-contracts.md | 5 + .../README.md | 27 ++- .../performance-panel.browser.test.tsx | 36 +++- .../collectors/README.md | 20 +-- .../collector-manager.browser.test.ts | 42 ++++- .../__tests__/paint-collector.browser.test.ts | 10 +- .../performance-types.browser.test.ts | 42 +++++ .../collectors/collector-manager.ts | 25 ++- .../collectors/constants.ts | 10 +- .../collectors/input-collector.ts | 6 +- .../collectors/paint-collector.ts | 10 +- .../collectors/style-mutation-collector.ts | 6 +- .../core/performance-types.ts | 158 ++++++++++++++++-- .../index-universal.ts | 10 +- .../index.ts | 10 +- .../performance-panel.tsx | 108 +++++++----- .../react/performance-decorator.tsx | 6 +- 17 files changed, 417 insertions(+), 114 deletions(-) create mode 100644 .changeset/correct-metric-contracts.md diff --git a/.changeset/correct-metric-contracts.md b/.changeset/correct-metric-contracts.md new file mode 100644 index 0000000..5a0c3be --- /dev/null +++ b/.changeset/correct-metric-contracts.md @@ -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. diff --git a/packages/storybook-addon-performance-panel/README.md b/packages/storybook-addon-performance-panel/README.md index aac5f45..88d5b4c 100644 --- a/packages/storybook-addon-performance-panel/README.md +++ b/packages/storybook-addon-performance-panel/README.md @@ -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 @@ -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 @@ -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 @@ -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. @@ -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** | @@ -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 @@ -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 diff --git a/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx b/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx index 4bb8452..e4fa534 100644 --- a/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx +++ b/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx @@ -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 void> @@ -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) { @@ -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 () => { @@ -70,4 +72,24 @@ 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') + }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/README.md b/packages/storybook-addon-performance-panel/collectors/README.md index 6af5185..15a3b62 100644 --- a/packages/storybook-addon-performance-panel/collectors/README.md +++ b/packages/storybook-addon-performance-panel/collectors/README.md @@ -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 | @@ -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: @@ -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) @@ -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') { @@ -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) @@ -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) --- @@ -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 diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts index 1248fda..3b4d125 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts @@ -1,6 +1,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {CollectorManager} from '../../collectors/collector-manager' +import {DOM_MUTATION_SAMPLE_INTERVAL_MS} from '../../collectors/style-mutation-collector' import type {RenderInfo} from '../../core/performance-types' /** @@ -228,8 +229,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 @@ -376,6 +377,41 @@ 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], + }) + + const metrics = manager.computeMetrics() + const deprecatedMetrics = metrics as unknown as Record + + 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((3 * 1000) / DOM_MUTATION_SAMPLE_INTERVAL_MS) + }) + it('rounds numeric values appropriately', () => { const metrics = manager.computeMetrics() @@ -383,7 +419,7 @@ describe('CollectorManager', () => { 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) }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/paint-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/paint-collector.browser.test.ts index 85bb351..7f54dbb 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/paint-collector.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/paint-collector.browser.test.ts @@ -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() @@ -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() @@ -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' @@ -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) @@ -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) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/performance-types.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/performance-types.browser.test.ts index f783f26..8cfccdb 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/performance-types.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/performance-types.browser.test.ts @@ -7,6 +7,7 @@ import { getZeroIsGoodStatus, PANEL_ID, PERF_EVENTS, + PERFORMANCE_METRIC_METADATA, THRESHOLDS, } from '../../core/performance-types' @@ -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 + 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', () => { @@ -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 + 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', () => { diff --git a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts index da528fe..8a37bab 100644 --- a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts +++ b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts @@ -19,7 +19,7 @@ import {MainThreadCollector} from './main-thread-collector' import {MemoryCollector} from './memory-collector' import {PaintCollector} from './paint-collector' import {ReactProfilerCollector} from './react-profiler-collector' -import {StyleMutationCollector} from './style-mutation-collector' +import {DOM_MUTATION_SAMPLE_INTERVAL_MS, StyleMutationCollector} from './style-mutation-collector' import type {MetricCollector} from './types' import {addToWindow, computeAverage, computeP95} from './utils' @@ -291,7 +291,13 @@ export class CollectorManager { const avgFrameTime = computeAverage(frame.frameTimes) const fps = avgFrameTime > 0 ? Math.round(1000 / avgFrameTime) : 0 const avgInputLatency = computeAverage(input.inputLatencies) - const avgPaintTime = computeAverage(input.paintTimes) + const avgPointerFrameInterval = computeAverage(input.paintTimes) + const pointerFrameInterval = Math.round(avgPointerFrameInterval * 10) / 10 + const maxPointerFrameInterval = Math.round(input.maxPaintTime * 10) / 10 + const scriptResourceLoadTime = Math.round(paint.scriptEvalTime * 10) / 10 + const averageDomMutationsPerSample = computeAverage(style.domMutationFrames) + const domMutationsPerSample = Math.round(averageDomMutationsPerSample) + const domMutationsPerSecond = Math.round((averageDomMutationsPerSample * 1000) / DOM_MUTATION_SAMPLE_INTERVAL_MS) const memoryDeltaMB = memory.lastMemoryMB !== null && memory.baselineMemoryMB !== null ? Math.round((memory.lastMemoryMB - memory.baselineMemoryMB) * 10) / 10 @@ -303,8 +309,12 @@ export class CollectorManager { maxFrameTime: Math.round(frame.maxFrameTime * 10) / 10, inputLatency: Math.round(avgInputLatency * 10) / 10, maxInputLatency: Math.round(input.maxInputLatency * 10) / 10, - paintTime: Math.round(avgPaintTime * 10) / 10, - maxPaintTime: Math.round(input.maxPaintTime * 10) / 10, + pointerFrameInterval, + maxPointerFrameInterval, + pointerFrameJitter: input.paintJitter, + initialPaintMilestones: paint.paintCount, + paintTime: pointerFrameInterval, + maxPaintTime: maxPointerFrameInterval, inputJitter: input.inputJitter, memoryUsedMB: memory.lastMemoryMB, memoryDeltaMB, @@ -354,12 +364,15 @@ export class CollectorManager { eventListenerCount: 0, // Not currently tracked by collectors observerCount: 0, // Not currently tracked by collectors cssVarChanges: style.cssVarChanges, - scriptEvalTime: Math.round(paint.scriptEvalTime * 10) / 10, + scriptResourceLoadTime, + scriptEvalTime: scriptResourceLoadTime, gcPressure: Math.round(memory.gcPressure * 100) / 100, paintCount: paint.paintCount, paintJitter: input.paintJitter, + layerPromotionCandidates: paint.compositorLayers, compositorLayers: paint.compositorLayers, - domMutationsPerFrame: Math.round(computeAverage(style.domMutationFrames)), + domMutationsPerSecond, + domMutationsPerFrame: domMutationsPerSample, slowReactUpdates: react.slowReactUpdates, reactP95Duration: computeP95(react.reactUpdateDurations), // Element Timing metrics diff --git a/packages/storybook-addon-performance-panel/collectors/constants.ts b/packages/storybook-addon-performance-panel/collectors/constants.ts index 82fa13c..ff200ce 100644 --- a/packages/storybook-addon-performance-panel/collectors/constants.ts +++ b/packages/storybook-addon-performance-panel/collectors/constants.ts @@ -32,7 +32,7 @@ export const FRAME_TIMES_WINDOW = 60 /** Rolling window size for input latency samples */ export const INPUT_LATENCIES_WINDOW = 30 -/** Rolling window size for paint time samples */ +/** Rolling window size for double-RAF pointer frame interval samples */ export const PAINT_TIMES_WINDOW = 30 /** Number of data points to keep for sparkline charts */ @@ -60,10 +60,10 @@ export const JITTER_FRAME_DELTA = 20 /** Minimum absolute value (ms) to count as frame jitter */ export const JITTER_FRAME_ABSOLUTE = 40 -/** Minimum delta (ms) from baseline to count as paint jitter */ +/** Minimum delta (ms) from baseline to count as pointer frame interval jitter */ export const JITTER_PAINT_DELTA = 20 -/** Minimum absolute value (ms) to count as paint jitter */ +/** Minimum absolute value (ms) to count as pointer frame interval jitter */ export const JITTER_PAINT_ABSOLUTE = 35 // ============================================================================ @@ -82,8 +82,8 @@ export const MAX_INPUT_DECAY_THRESHOLD = 20 /** Decay rate per frame for max input latency */ export const MAX_INPUT_DECAY_RATE = 0.98 -/** Threshold below which max paint time starts to decay */ +/** Threshold below which the max pointer frame interval starts to decay */ export const MAX_PAINT_DECAY_THRESHOLD = 10 -/** Decay rate per frame for max paint time */ +/** Decay rate per frame for the max pointer frame interval */ export const MAX_PAINT_DECAY_RATE = 0.98 diff --git a/packages/storybook-addon-performance-panel/collectors/input-collector.ts b/packages/storybook-addon-performance-panel/collectors/input-collector.ts index fa23267..8516c1f 100644 --- a/packages/storybook-addon-performance-panel/collectors/input-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/input-collector.ts @@ -8,7 +8,7 @@ * - Uses browser-provided targetSelector for element identification * * Differences from web-vitals (intentional for Storybook use case): - * - Tracks additional metrics: jitter, paint times, detailed breakdowns + * - Tracks additional metrics: jitter, pointer frame intervals, detailed breakdowns * - No soft-nav integration (not needed in Storybook) * - Simpler Map-based storage (stories are short-lived, not memory-constrained) * @@ -95,7 +95,7 @@ export interface InputMetrics { * - INP (Interaction to Next Paint) - p98 of worst interactions * - Input delay breakdown (input delay, processing time, presentation delay) * - Input latency via pointermove (RAF-based for continuous tracking) - * - Paint time estimation + * - Double-RAF pointer frame interval heuristic * - Input jitter * * @see https://web.dev/articles/inp @@ -407,7 +407,7 @@ export class InputCollector implements MetricCollector { const latency = rafTime - eventTime this.#processInput(latency) - // Paint time measurement via double-RAF + // Measure the interval between consecutive RAFs after pointer movement. requestAnimationFrame(() => { const paintEnd = performance.now() const paintTime = paintEnd - rafTime diff --git a/packages/storybook-addon-performance-panel/collectors/paint-collector.ts b/packages/storybook-addon-performance-panel/collectors/paint-collector.ts index ec8be8d..d7659db 100644 --- a/packages/storybook-addon-performance-panel/collectors/paint-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/paint-collector.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Paint and resource timing metrics collector + * @fileoverview Initial paint milestones, script resource timing, and layer-promotion heuristics * @module collectors/PaintCollector */ @@ -31,9 +31,9 @@ function cancelIdle(id: number): void { } /** - * Collects paint and resource timing metrics. + * Collects initial paint milestones, script resource loading time, and layer-promotion candidates. * - * Compositor layer tracking uses a MutationObserver to incrementally detect + * Layer-promotion candidate tracking uses a MutationObserver to incrementally detect * style/class/childList changes and defers getComputedStyle checks to idle * periods via requestIdleCallback, avoiding the observer effect of inflating * frame timing and main thread metrics. @@ -73,7 +73,7 @@ export class PaintCollector implements MetricCollector { /* Not supported */ } - // Resource observer for script timing + // Resource observer for script loading duration try { this.#resourceObserver = new PerformanceObserver(list => { for (const entry of list.getEntries()) { @@ -94,7 +94,7 @@ export class PaintCollector implements MetricCollector { /* Not supported */ } - // Start incremental compositor layer tracking + // Start incremental layer-promotion candidate tracking this.#startLayerTracking() } diff --git a/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts b/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts index 31b7ae4..9fa2654 100644 --- a/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts @@ -7,6 +7,8 @@ import {THRASHING_FRAME_THRESHOLD, THRASHING_STYLE_WRITE_WINDOW} from './constan import type {MetricCollector} from './types' import {addToWindow} from './utils' +export const DOM_MUTATION_SAMPLE_INTERVAL_MS = 200 + export interface StyleMetrics { styleWrites: number cssVarChanges: number @@ -20,7 +22,7 @@ export interface StyleMetrics { * Tracks: * - Style attribute mutations * - CSS variable changes - * - DOM mutations per frame + * - DOM mutations in fixed 200ms sample windows * - Layout thrashing score */ export class StyleMutationCollector implements MetricCollector { @@ -79,7 +81,7 @@ export class StyleMutationCollector implements MetricCollector { this.#sampleInterval = setInterval(() => { addToWindow(this.#domMutationFrames, this.#domMutationCount, 30) this.#domMutationCount = 0 - }, 200) + }, DOM_MUTATION_SAMPLE_INTERVAL_MS) } stop(): void { diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index ae9ea7a..342f5b6 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -215,9 +215,13 @@ export const THRESHOLDS = { FORCED_REFLOW_WARNING: 5, /** Forced reflows above this is serious */ FORCED_REFLOW_DANGER: 20, - /** DOM mutations/frame above this may cause jank */ + /** DOM mutations/second above this may cause jank */ + DOM_MUTATIONS_PER_SECOND_WARNING: 250, + /** DOM mutations/second above this likely causes jank */ + DOM_MUTATIONS_PER_SECOND_DANGER: 1000, + /** @deprecated Use DOM_MUTATIONS_PER_SECOND_WARNING. */ DOM_MUTATIONS_WARNING: 50, - /** DOM mutations/frame above this likely causes jank */ + /** @deprecated Use DOM_MUTATIONS_PER_SECOND_DANGER. */ DOM_MUTATIONS_DANGER: 200, // ───────────────────────────────────────────────────────────────────────── @@ -263,9 +267,9 @@ export const THRESHOLDS = { OBSERVERS_DANGER: 25, /** CSS var changes above this is excessive */ CSS_VAR_CHANGES_WARNING: 50, - /** Compositor layers above this needs attention */ + /** Layer-promotion candidates above this needs attention */ LAYERS_WARNING: 20, - /** Compositor layers above this is concerning */ + /** Layer-promotion candidates above this is concerning */ LAYERS_DANGER: 50, } as const @@ -386,13 +390,21 @@ export interface PerformanceMetrics { // ───────────────────────────────────────────────────────────────────────── // Paint Performance // ───────────────────────────────────────────────────────────────────────── - /** Average paint time (ms) */ + /** Average interval between the first and second animation frames after a pointer move (ms) */ + pointerFrameInterval: number + /** Peak pointer frame interval with decay (ms) */ + maxPointerFrameInterval: number + /** Pointer frame interval jitter count */ + pointerFrameJitter: number + /** Number of native initial paint milestones observed */ + initialPaintMilestones: number + /** @deprecated Use pointerFrameInterval. */ paintTime: number - /** Peak paint time with decay (ms) */ + /** @deprecated Use maxPointerFrameInterval. */ maxPaintTime: number - /** Total paint operations observed */ + /** @deprecated Use initialPaintMilestones. */ paintCount: number - /** Paint jitter count - sudden spikes in paint time vs baseline */ + /** @deprecated Use pointerFrameJitter. */ paintJitter: number // ───────────────────────────────────────────────────────────────────────── @@ -466,7 +478,9 @@ export interface PerformanceMetrics { currentSessionCLS: number /** Synchronous reads that forced browser reflow */ forcedReflowCount: number - /** Average DOM mutations per frame. High: >50 */ + /** Average DOM mutations normalized to a one-second rate */ + domMutationsPerSecond: number + /** @deprecated Use domMutationsPerSecond. This is the average count per 200ms sample. */ domMutationsPerFrame: number /** CSS custom property changes */ cssVarChanges: number @@ -496,17 +510,21 @@ export interface PerformanceMetrics { // ───────────────────────────────────────────────────────────────────────── /** Current DOM element count in story container */ domElements: number | null - /** Script evaluation time (ms) */ + /** Cumulative script resource loading time from the Resource Timing API (ms) */ + scriptResourceLoadTime: number + /** @deprecated Use scriptResourceLoadTime. */ scriptEvalTime: number // ───────────────────────────────────────────────────────────────────────── // Observer Counts (informational) // ───────────────────────────────────────────────────────────────────────── - /** Active event listeners (when trackable) */ + /** @deprecated Unsupported and always 0. */ eventListenerCount: number - /** Active observers (Intersection, Mutation, Resize) */ + /** @deprecated Unsupported and always 0. */ observerCount: number - /** Compositor layers (DevTools protocol, often null) */ + /** Elements matching CSS layer-promotion heuristics. Null until the initial scan completes. */ + layerPromotionCandidates: number | null + /** @deprecated Use layerPromotionCandidates. This is not a browser compositor layer count. */ compositorLayers: number | null // ───────────────────────────────────────────────────────────────────────── @@ -522,6 +540,113 @@ export interface PerformanceMetrics { elementTimings: {identifier: string; renderTime: number; selector: string}[] } +/** How a metric is obtained from its underlying browser or framework signal. */ +export type MetricProvenance = 'native' | 'derived' | 'heuristic' | 'unsupported' + +/** Confidence in how faithfully a metric represents the behavior named by its public contract. */ +export type MetricQuality = 'high' | 'medium' | 'low' | 'unavailable' + +/** Units used by public metric contracts. */ +export type MetricUnit = + | 'boolean' + | 'count' + | 'frames-per-second' + | 'megabytes' + | 'megabytes-per-second' + | 'milliseconds' + | 'percent' + | 'per-second' + | 'score' + | 'structured' + | 'text' + +/** Static metadata for a public performance metric. */ +export interface PerformanceMetricMetadata { + provenance: MetricProvenance + quality: MetricQuality + unit: MetricUnit +} + +/** + * Provenance, confidence, and units for every public metric. + * Static metadata avoids repeating invariant descriptions in every live metrics payload. + */ +export const PERFORMANCE_METRIC_METADATA = { + fps: {provenance: 'derived', quality: 'medium', unit: 'frames-per-second'}, + frameTime: {provenance: 'derived', quality: 'medium', unit: 'milliseconds'}, + maxFrameTime: {provenance: 'derived', quality: 'medium', unit: 'milliseconds'}, + droppedFrames: {provenance: 'derived', quality: 'medium', unit: 'count'}, + frameJitter: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + frameStability: {provenance: 'heuristic', quality: 'low', unit: 'percent'}, + inputLatency: {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}, + maxInputLatency: {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}, + inputJitter: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + eventTimingSupported: {provenance: 'native', quality: 'high', unit: 'boolean'}, + interactionCount: {provenance: 'derived', quality: 'medium', unit: 'count'}, + inpMs: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + firstInputDelay: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + firstInputType: {provenance: 'native', quality: 'high', unit: 'text'}, + lastInteraction: {provenance: 'native', quality: 'high', unit: 'structured'}, + slowestInteraction: {provenance: 'derived', quality: 'high', unit: 'structured'}, + interactionsByType: {provenance: 'derived', quality: 'high', unit: 'structured'}, + pointerFrameInterval: {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}, + maxPointerFrameInterval: {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}, + pointerFrameJitter: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + initialPaintMilestones: {provenance: 'native', quality: 'high', unit: 'count'}, + paintTime: {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}, + maxPaintTime: {provenance: 'heuristic', quality: 'low', unit: 'milliseconds'}, + paintCount: {provenance: 'native', quality: 'high', unit: 'count'}, + paintJitter: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + memoryUsedMB: {provenance: 'native', quality: 'medium', unit: 'megabytes'}, + memoryDeltaMB: {provenance: 'derived', quality: 'medium', unit: 'megabytes'}, + peakMemoryMB: {provenance: 'derived', quality: 'medium', unit: 'megabytes'}, + gcPressure: {provenance: 'heuristic', quality: 'low', unit: 'megabytes-per-second'}, + fpsHistory: {provenance: 'derived', quality: 'medium', unit: 'frames-per-second'}, + frameTimeHistory: {provenance: 'derived', quality: 'medium', unit: 'milliseconds'}, + memoryHistory: {provenance: 'derived', quality: 'medium', unit: 'megabytes'}, + longTasks: {provenance: 'native', quality: 'high', unit: 'count'}, + longestTask: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + totalBlockingTime: {provenance: 'derived', quality: 'medium', unit: 'milliseconds'}, + loafSupported: {provenance: 'native', quality: 'high', unit: 'boolean'}, + loafCount: {provenance: 'native', quality: 'high', unit: 'count'}, + totalLoafBlockingDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + longestLoafDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + longestLoafBlockingDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + avgLoafDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + p95LoafDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + loafsWithScripts: {provenance: 'derived', quality: 'high', unit: 'count'}, + lastLoaf: {provenance: 'native', quality: 'high', unit: 'structured'}, + worstLoaf: {provenance: 'derived', quality: 'high', unit: 'structured'}, + styleWrites: {provenance: 'derived', quality: 'high', unit: 'count'}, + thrashingScore: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + layoutShiftScore: {provenance: 'derived', quality: 'high', unit: 'score'}, + layoutShiftCount: {provenance: 'native', quality: 'high', unit: 'count'}, + currentSessionCLS: {provenance: 'derived', quality: 'high', unit: 'score'}, + forcedReflowCount: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + domMutationsPerSecond: {provenance: 'derived', quality: 'medium', unit: 'per-second'}, + domMutationsPerFrame: {provenance: 'derived', quality: 'medium', unit: 'count'}, + cssVarChanges: {provenance: 'derived', quality: 'medium', unit: 'count'}, + reactRenderCount: {provenance: 'native', quality: 'high', unit: 'count'}, + reactMountCount: {provenance: 'derived', quality: 'high', unit: 'count'}, + reactMountDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + reactPostMountUpdateCount: {provenance: 'derived', quality: 'high', unit: 'count'}, + reactPostMountMaxDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + reactP95Duration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + slowReactUpdates: {provenance: 'derived', quality: 'high', unit: 'count'}, + renderCascades: {provenance: 'derived', quality: 'high', unit: 'count'}, + domElements: {provenance: 'derived', quality: 'high', unit: 'count'}, + scriptResourceLoadTime: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + scriptEvalTime: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + eventListenerCount: {provenance: 'unsupported', quality: 'unavailable', unit: 'count'}, + observerCount: {provenance: 'unsupported', quality: 'unavailable', unit: 'count'}, + layerPromotionCandidates: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + compositorLayers: {provenance: 'heuristic', quality: 'low', unit: 'count'}, + elementTimingSupported: {provenance: 'native', quality: 'high', unit: 'boolean'}, + elementTimingCount: {provenance: 'derived', quality: 'high', unit: 'count'}, + largestElementRenderTime: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, + elementTimings: {provenance: 'native', quality: 'high', unit: 'structured'}, +} as const satisfies Record + /** * Default/initial metrics state (all zeros/nulls). * Used when panel first loads or after reset. @@ -544,6 +669,10 @@ export const DEFAULT_METRICS: PerformanceMetrics = { lastInteraction: null, slowestInteraction: null, interactionsByType: {}, + pointerFrameInterval: 0, + maxPointerFrameInterval: 0, + pointerFrameJitter: 0, + initialPaintMilestones: 0, paintTime: 0, maxPaintTime: 0, paintCount: 0, @@ -576,6 +705,7 @@ export const DEFAULT_METRICS: PerformanceMetrics = { layoutShiftCount: 0, currentSessionCLS: 0, forcedReflowCount: 0, + domMutationsPerSecond: 0, domMutationsPerFrame: 0, cssVarChanges: 0, reactRenderCount: 0, @@ -587,9 +717,11 @@ export const DEFAULT_METRICS: PerformanceMetrics = { slowReactUpdates: 0, renderCascades: 0, domElements: null, + scriptResourceLoadTime: 0, scriptEvalTime: 0, eventListenerCount: 0, observerCount: 0, + layerPromotionCandidates: null, compositorLayers: null, // Element Timing elementTimingSupported: true, // Assume supported until told otherwise diff --git a/packages/storybook-addon-performance-panel/index-universal.ts b/packages/storybook-addon-performance-panel/index-universal.ts index e2ab78d..f64c0e8 100644 --- a/packages/storybook-addon-performance-panel/index-universal.ts +++ b/packages/storybook-addon-performance-panel/index-universal.ts @@ -21,5 +21,13 @@ import addonAnnotations from './preview-universal' const start = () => definePreviewAddon(addonAnnotations) export default start -export type {PerformancePanelParameters} from './core/performance-types' +export type { + MetricProvenance, + MetricQuality, + MetricUnit, + PerformanceMetricMetadata, + PerformanceMetrics, + PerformancePanelParameters, +} from './core/performance-types' +export {PERFORMANCE_METRIC_METADATA} from './core/performance-types' export {withPerformanceMonitor} from './decorators/universal' diff --git a/packages/storybook-addon-performance-panel/index.ts b/packages/storybook-addon-performance-panel/index.ts index 958e449..b54ce72 100644 --- a/packages/storybook-addon-performance-panel/index.ts +++ b/packages/storybook-addon-performance-panel/index.ts @@ -6,5 +6,13 @@ const start = () => definePreviewAddon(addonAnnotations) export default start // Public API for manual per-story usage (framework-agnostic) -export type {PerformancePanelParameters} from './core/performance-types' +export type { + MetricProvenance, + MetricQuality, + MetricUnit, + PerformanceMetricMetadata, + PerformanceMetrics, + PerformancePanelParameters, +} from './core/performance-types' +export {PERFORMANCE_METRIC_METADATA} from './core/performance-types' export {withPerformanceMonitor} from './decorators/universal' diff --git a/packages/storybook-addon-performance-panel/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx index 714e4eb..c9f8e66 100644 --- a/packages/storybook-addon-performance-panel/performance-panel.tsx +++ b/packages/storybook-addon-performance-panel/performance-panel.tsx @@ -354,9 +354,9 @@ type FrameTimingSectionProps = Pick< | 'droppedFrames' | 'frameJitter' | 'frameStability' - | 'paintTime' - | 'maxPaintTime' - | 'paintJitter' + | 'pointerFrameInterval' + | 'maxPointerFrameInterval' + | 'pointerFrameJitter' > const FrameTimingSection = React.memo(function FrameTimingSection({ @@ -368,16 +368,16 @@ const FrameTimingSection = React.memo(function FrameTimingSection({ droppedFrames, frameJitter, frameStability, - paintTime, - maxPaintTime, - paintJitter, + pointerFrameInterval, + maxPointerFrameInterval, + pointerFrameJitter, }: FrameTimingSectionProps) { const fpsStatus = getStatus(fps, THRESHOLDS.FPS_GOOD, THRESHOLDS.FPS_WARNING, true) const droppedStatus = droppedFrames > THRESHOLDS.DROPPED_FRAMES_WARNING ? 'error' : droppedFrames > 0 ? 'warning' : 'success' const frameJitterStatus = getZeroStatus(frameJitter) const stabilityStatus = frameStability >= 90 ? 'success' : frameStability >= 70 ? 'warning' : 'error' - const paintJitterStatus = getZeroStatus(paintJitter) + const pointerFrameJitterStatus = getZeroStatus(pointerFrameJitter) return ( @@ -437,17 +437,17 @@ const FrameTimingSection = React.memo(function FrameTimingSection({ - - {formatMs(paintTime)} - / {formatMs(maxPaintTime)} max - - - - {paintJitter === 0 ? '✨ None' : `🎢 ${String(paintJitter)} spikes`} + {formatMs(pointerFrameInterval)} + / {formatMs(maxPointerFrameInterval)} max + + + + + {pointerFrameJitter === 0 ? '✨ None' : `🎢 ${String(pointerFrameJitter)} spikes`} @@ -693,13 +693,13 @@ const InputSection = React.memo(function InputSection({ * - Long Tasks: Tasks >50ms blocking the main thread * - TBT: Total Blocking Time (Core Web Vital correlate) * - Thrashing: Style write + forced layout combinations - * - DOM Churn: Mutations per measurement period + * - DOM Churn: Mutations normalized to a per-second rate * * @component */ type MainThreadSectionProps = Pick< PerformanceMetrics, - 'longTasks' | 'longestTask' | 'totalBlockingTime' | 'thrashingScore' | 'domMutationsPerFrame' + 'longTasks' | 'longestTask' | 'totalBlockingTime' | 'thrashingScore' | 'domMutationsPerSecond' > const MainThreadSection = React.memo(function MainThreadSection({ @@ -707,12 +707,12 @@ const MainThreadSection = React.memo(function MainThreadSection({ longestTask, totalBlockingTime, thrashingScore, - domMutationsPerFrame, + domMutationsPerSecond, }: MainThreadSectionProps) { const longTaskStatus = getStatus(longTasks, 0, THRESHOLDS.LONG_TASKS_WARNING) const tbtStatus = getStatus(totalBlockingTime, 0, THRESHOLDS.TBT_WARNING) const thrashingStatus = getZeroStatus(thrashingScore) - const domMutationStatus = getStatus(domMutationsPerFrame, 0, THRESHOLDS.DOM_MUTATIONS_WARNING) + const domMutationStatus = getStatus(domMutationsPerSecond, 0, THRESHOLDS.DOM_MUTATIONS_PER_SECOND_WARNING) return ( @@ -744,12 +744,18 @@ const MainThreadSection = React.memo(function MainThreadSection({ - + - {domMutationsPerFrame === 0 ? '✨ ' : domMutationsPerFrame > 10 ? '🌪️ ' : '🔨 '} - {domMutationsPerFrame} + + {domMutationsPerSecond === 0 + ? '✨ ' + : domMutationsPerSecond > THRESHOLDS.DOM_MUTATIONS_PER_SECOND_WARNING + ? '🌪️ ' + : '🔨 '} + + {domMutationsPerSecond} - /period + /s ) @@ -1234,7 +1240,7 @@ const ReactSection = React.memo(function ReactSection({profilers = EMPTY_PROFILE * - Heap: Current JS heap size with sparkline (Chrome only) * - Peak / DOM: Peak memory and DOM node count * - GC Pressure: Memory allocation rate (MB/s) - * - Paint / Layers: Paint count and compositor layers + * - Paint milestones / layer candidates: Native milestones and CSS promotion heuristics * * Shows alternate view when memory API is unavailable (Firefox/Safari). * @@ -1248,8 +1254,8 @@ type MemoryAndRenderingSectionProps = Pick< | 'memoryHistory' | 'gcPressure' | 'domElements' - | 'paintCount' - | 'compositorLayers' + | 'initialPaintMilestones' + | 'layerPromotionCandidates' > const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection({ @@ -1259,11 +1265,12 @@ const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection( memoryHistory, gcPressure, domElements, - paintCount, - compositorLayers, + initialPaintMilestones, + layerPromotionCandidates, }: MemoryAndRenderingSectionProps) { const gcStatus = getStatus(gcPressure, 0, THRESHOLDS.GC_PRESSURE_WARNING) - const layerStatus = compositorLayers === null ? 'neutral' : getStatus(compositorLayers, 0, THRESHOLDS.LAYERS_WARNING) + const layerStatus = + layerPromotionCandidates === null ? 'neutral' : getStatus(layerPromotionCandidates, 0, THRESHOLDS.LAYERS_WARNING) const deltaStatus = memoryDeltaMB === null @@ -1289,11 +1296,21 @@ const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection( Not available (Chrome only) - - {paintCount} + + {initialPaintMilestones} - - {compositorLayers !== null ? {compositorLayers} : '—'} + + {layerPromotionCandidates !== null ? ( + {layerPromotionCandidates} + ) : ( + '—' + )} ) @@ -1326,12 +1343,15 @@ const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection( - - {paintCount} + + {initialPaintMilestones} /{' '} - {compositorLayers !== null ? ( - {compositorLayers} layers + {layerPromotionCandidates !== null ? ( + {layerPromotionCandidates} candidates ) : ( )} @@ -1605,9 +1625,9 @@ function ConnectedPanelContent({storyId}: {storyId: string}) { droppedFrames={metrics.droppedFrames} frameJitter={metrics.frameJitter} frameStability={metrics.frameStability} - paintTime={metrics.paintTime} - maxPaintTime={metrics.maxPaintTime} - paintJitter={metrics.paintJitter} + pointerFrameInterval={metrics.pointerFrameInterval} + maxPointerFrameInterval={metrics.maxPointerFrameInterval} + pointerFrameJitter={metrics.pointerFrameJitter} /> 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 * * ### Layout Stability * - **CLS (Cumulative Layout Shift)**: Layout shift score without user input @@ -66,7 +66,7 @@ * - **Heap Usage**: Current JS heap size (Chrome only via performance.memory) * - **Memory Delta**: Change from baseline since last reset * - **GC Pressure**: Memory allocation rate in MB/s - * - **Compositor Layers**: Elements promoted to GPU layers + * - **Layer-Promotion Candidates**: Elements matching CSS promotion heuristics * * @module performance-decorator * @see {@link ./performance-panel.tsx} - The UI that displays these metrics From 8cc733078b0c6bf17b69b59ae9a110b88ceca2c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:25:57 +0000 Subject: [PATCH 2/2] Fix Copilot review-thread feedback Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../__tests__/performance-panel.browser.test.tsx | 5 +++++ .../__tests__/collector-manager.browser.test.ts | 4 ++-- .../style-mutation-collector.browser.test.ts | 2 ++ .../collectors/collector-manager.ts | 12 +++++++++++- .../collectors/style-mutation-collector.ts | 11 +++++++++++ .../core/performance-types.ts | 4 ++-- 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx b/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx index e4fa534..8f7bed4 100644 --- a/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx +++ b/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx @@ -91,5 +91,10 @@ describe('PerformancePanel visibility', () => { 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') + 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/) }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts index 3b4d125..7fb5474 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/collector-manager.browser.test.ts @@ -1,7 +1,6 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {CollectorManager} from '../../collectors/collector-manager' -import {DOM_MUTATION_SAMPLE_INTERVAL_MS} from '../../collectors/style-mutation-collector' import type {RenderInfo} from '../../core/performance-types' /** @@ -396,6 +395,7 @@ describe('CollectorManager', () => { vi.spyOn(manager.collectors.style, 'getMetrics').mockReturnValue({ ...styleMetrics, domMutationFrames: [2, 4], + domMutationSampleDurationsMs: [200, 400], }) const metrics = manager.computeMetrics() @@ -409,7 +409,7 @@ describe('CollectorManager', () => { expect(metrics.scriptResourceLoadTime).toBe(deprecatedMetrics.scriptEvalTime) expect(metrics.layerPromotionCandidates).toBe(deprecatedMetrics.compositorLayers) expect(deprecatedMetrics.domMutationsPerFrame).toBe(3) - expect(metrics.domMutationsPerSecond).toBe((3 * 1000) / DOM_MUTATION_SAMPLE_INTERVAL_MS) + expect(metrics.domMutationsPerSecond).toBe(10) }) it('rounds numeric values appropriately', () => { diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts index 18262d3..e870546 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts @@ -22,6 +22,7 @@ describe('StyleMutationCollector', () => { expect(metrics.styleWrites).toBe(0) expect(metrics.cssVarChanges).toBe(0) expect(metrics.domMutationFrames).toEqual([]) + expect(metrics.domMutationSampleDurationsMs).toEqual([]) expect(metrics.thrashingScore).toBe(0) }) }) @@ -166,6 +167,7 @@ describe('StyleMutationCollector', () => { expect(metrics.styleWrites).toBe(0) expect(metrics.cssVarChanges).toBe(0) expect(metrics.domMutationFrames).toEqual([]) + expect(metrics.domMutationSampleDurationsMs).toEqual([]) expect(metrics.thrashingScore).toBe(0) document.body.removeChild(el) diff --git a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts index 8a37bab..dd44ed9 100644 --- a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts +++ b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts @@ -296,8 +296,18 @@ export class CollectorManager { const maxPointerFrameInterval = Math.round(input.maxPaintTime * 10) / 10 const scriptResourceLoadTime = Math.round(paint.scriptEvalTime * 10) / 10 const averageDomMutationsPerSample = computeAverage(style.domMutationFrames) + const totalDomMutations = style.domMutationFrames.reduce((sum, count) => sum + count, 0) + const hasSampleDurations = + style.domMutationSampleDurationsMs.length === style.domMutationFrames.length && + style.domMutationSampleDurationsMs.length > 0 + const totalDomMutationSampleDurationMs = hasSampleDurations + ? style.domMutationSampleDurationsMs.reduce((sum, duration) => sum + duration, 0) + : style.domMutationFrames.length * DOM_MUTATION_SAMPLE_INTERVAL_MS const domMutationsPerSample = Math.round(averageDomMutationsPerSample) - const domMutationsPerSecond = Math.round((averageDomMutationsPerSample * 1000) / DOM_MUTATION_SAMPLE_INTERVAL_MS) + const domMutationsPerSecond = + totalDomMutationSampleDurationMs > 0 + ? Math.round((totalDomMutations * 1000) / totalDomMutationSampleDurationMs) + : 0 const memoryDeltaMB = memory.lastMemoryMB !== null && memory.baselineMemoryMB !== null ? Math.round((memory.lastMemoryMB - memory.baselineMemoryMB) * 10) / 10 diff --git a/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts b/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts index 9fa2654..d65a733 100644 --- a/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts @@ -13,6 +13,7 @@ export interface StyleMetrics { styleWrites: number cssVarChanges: number domMutationFrames: number[] + domMutationSampleDurationsMs: number[] thrashingScore: number } @@ -33,6 +34,8 @@ export class StyleMutationCollector implements MetricCollector { #styleWriteCount = 0 #lastStyleWriteTime = 0 #domMutationCount = 0 + #domMutationSampleDurationsMs: number[] = [] + #lastDomMutationSampleTime = 0 #observer: MutationObserver | null = null #sampleInterval: ReturnType | null = null @@ -78,8 +81,13 @@ export class StyleMutationCollector implements MetricCollector { }) // Sample DOM mutations periodically + this.#lastDomMutationSampleTime = performance.now() this.#sampleInterval = setInterval(() => { + const now = performance.now() + const elapsedMs = Math.max(1, now - this.#lastDomMutationSampleTime) + this.#lastDomMutationSampleTime = now addToWindow(this.#domMutationFrames, this.#domMutationCount, 30) + addToWindow(this.#domMutationSampleDurationsMs, elapsedMs, 30) this.#domMutationCount = 0 }, DOM_MUTATION_SAMPLE_INTERVAL_MS) } @@ -98,6 +106,8 @@ export class StyleMutationCollector implements MetricCollector { this.#thrashingScore = 0 this.#styleWriteCount = 0 this.#domMutationCount = 0 + this.#domMutationSampleDurationsMs = [] + this.#lastDomMutationSampleTime = 0 } /** Call on each frame to check for thrashing */ @@ -118,6 +128,7 @@ export class StyleMutationCollector implements MetricCollector { styleWrites: this.#styleWrites, cssVarChanges: this.#cssVarChanges, domMutationFrames: this.#domMutationFrames, + domMutationSampleDurationsMs: this.#domMutationSampleDurationsMs, thrashingScore: this.#thrashingScore, } } diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index 342f5b6..b4417ea 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -267,9 +267,9 @@ export const THRESHOLDS = { OBSERVERS_DANGER: 25, /** CSS var changes above this is excessive */ CSS_VAR_CHANGES_WARNING: 50, - /** Layer-promotion candidates above this needs attention */ + /** Layer-promotion candidate count above this needs attention */ LAYERS_WARNING: 20, - /** Layer-promotion candidates above this is concerning */ + /** Layer-promotion candidate count above this is concerning */ LAYERS_DANGER: 50, } as const