From 5574b234342798079d0dbd8593bf85e532e28f7e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 14:39:16 +0000 Subject: [PATCH] Improve frame timing fidelity --- .changeset/improve-frame-fidelity.md | 5 + .../README.md | 5 +- .../performance-panel.browser.test.tsx | 6 + .../collectors/README.md | 16 ++- .../collector-manager.browser.test.ts | 7 +- .../frame-timing-collector.browser.test.ts | 127 +++++++++++++----- .../performance-types.browser.test.ts | 3 + .../collectors/collector-manager.ts | 7 +- .../collectors/constants.ts | 17 ++- .../collectors/frame-timing-collector.ts | 113 +++++++++++++--- .../core/performance-types.ts | 25 +++- .../performance-panel.tsx | 52 +++++-- .../react/performance-decorator.tsx | 4 +- 13 files changed, 304 insertions(+), 83 deletions(-) create mode 100644 .changeset/improve-frame-fidelity.md diff --git a/.changeset/improve-frame-fidelity.md b/.changeset/improve-frame-fidelity.md new file mode 100644 index 0000000..5fc6375 --- /dev/null +++ b/.changeset/improve-frame-fidelity.md @@ -0,0 +1,5 @@ +--- +'@github-ui/storybook-addon-performance-panel': minor +--- + +Calibrate frame budgets to the display refresh rate, report observed intervals separately from inferred dropped frames, and exclude inactive iframe gaps from frame metrics. \ No newline at end of file diff --git a/packages/storybook-addon-performance-panel/README.md b/packages/storybook-addon-performance-panel/README.md index 88d5b4c..8d17220 100644 --- a/packages/storybook-addon-performance-panel/README.md +++ b/packages/storybook-addon-performance-panel/README.md @@ -66,8 +66,9 @@ The addon consists of two main parts: ### Frame Timing - **FPS**: Frames per second (target: 60fps) -- **Frame Time**: Average milliseconds per frame (target: ≤16.67ms) -- **Dropped Frames**: Frames exceeding 2× the expected frame time +- **Frame Time**: Average observed RAF interval with a refresh-rate-aware frame budget +- **Inferred Drops**: Missed refresh opportunities inferred from the calibrated budget; inactive iframe gaps are excluded +- **Refresh Estimate**: Display refresh rate estimated from stable RAF intervals - **Frame Jitter**: Sudden spikes in frame time vs baseline - **Frame Stability**: Percentage indicating frame time consistency 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..64b59a1 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 @@ -85,11 +85,17 @@ describe('PerformancePanel visibility', () => { domMutationsPerSecond: 25, initialPaintMilestones: 2, layerPromotionCandidates: 3, + estimatedRefreshRate: 120, + frameBudget: 8.33, + observedFrameIntervals: 20, + inferredDroppedFrames: 2, }) 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') + await expect.poll(() => document.body.textContent).toContain('Inferred Drops') + await expect.poll(() => document.body.textContent).toContain('120 Hz estimate') }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/README.md b/packages/storybook-addon-performance-panel/collectors/README.md index 15a3b62..fd6261d 100644 --- a/packages/storybook-addon-performance-panel/collectors/README.md +++ b/packages/storybook-addon-performance-panel/collectors/README.md @@ -32,7 +32,11 @@ This directory contains modular metric collector classes used by the performance ### Metrics - `frameTimes[]` - Rolling window of frame durations - `maxFrameTime` - Peak frame time with decay -- `droppedFrames` - Frames exceeding 2× budget (33.34ms) +- `estimatedRefreshRate` - Display refresh rate estimated from stable RAF intervals +- `frameBudget` - Milliseconds available per refresh at the estimated rate +- `observedFrameIntervals` - Valid consecutive RAF intervals +- `inferredDroppedFrames` - Missed refresh opportunities inferred from observed intervals +- `excludedFrameIntervals` - Inactive or throttled iframe gaps excluded from metrics - `frameJitter` - Count of sudden frame time spikes - `frameStability` - Consistency score (0-100%) @@ -41,10 +45,9 @@ This directory contains modular metric collector classes used by the performance ```typescript // Measures delta between consecutive RAF callbacks -#measure = (): void => { - const now = performance.now() - const delta = now - this.#lastTime - this.#lastTime = now +#measure = (timestamp: DOMHighResTimeStamp): void => { + const delta = timestamp - this.#lastTime + this.#lastTime = timestamp this.#processFrame(delta) this.#animationId = requestAnimationFrame(this.#measure) } @@ -54,10 +57,11 @@ This directory contains modular metric collector classes used by the performance - No direct browser API exists for frame-level timing - RAF callbacks are tied to the display refresh cycle - Delta between callbacks approximates actual frame duration +- Stable intervals calibrate the budget for 60, 120, 144 Hz, and other displays **Limitations:** - Cannot detect frames where RAF was not called -- Background tabs may have throttled RAF +- Background or inactive iframe gaps are excluded and trigger recalibration - Does not account for compositor frame timing --- 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..b59c118 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 @@ -243,7 +243,11 @@ describe('CollectorManager', () => { expect(metrics).toEqual({ frameTimes: [], maxFrameTime: 0, - droppedFrames: 0, + estimatedRefreshRate: null, + frameBudget: null, + observedFrameIntervals: 0, + inferredDroppedFrames: 0, + excludedFrameIntervals: 0, frameJitter: 0, frameStability: 100, }) @@ -408,6 +412,7 @@ describe('CollectorManager', () => { expect(metrics.initialPaintMilestones).toBe(deprecatedMetrics.paintCount) expect(metrics.scriptResourceLoadTime).toBe(deprecatedMetrics.scriptEvalTime) expect(metrics.layerPromotionCandidates).toBe(deprecatedMetrics.compositorLayers) + expect(metrics.inferredDroppedFrames).toBe(deprecatedMetrics.droppedFrames) expect(deprecatedMetrics.domMutationsPerFrame).toBe(3) expect(metrics.domMutationsPerSecond).toBe((3 * 1000) / DOM_MUTATION_SAMPLE_INTERVAL_MS) }) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/frame-timing-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/frame-timing-collector.browser.test.ts index e7dfa6d..11d8325 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/frame-timing-collector.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/frame-timing-collector.browser.test.ts @@ -8,6 +8,21 @@ describe('FrameTimingCollector', () => { let rafId = 0 let hidden = false + function runFrame(timestamp: number): void { + rafCallback?.(timestamp) + } + + function runSteadyFrames(refreshRate: number, intervalCount: number, startTime = 0): number { + const interval = 1000 / refreshRate + let timestamp = startTime + runFrame(timestamp) + for (let index = 0; index < intervalCount; index++) { + timestamp += interval + runFrame(timestamp) + } + return timestamp + } + beforeEach(() => { // Mock requestAnimationFrame vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { @@ -17,7 +32,6 @@ describe('FrameTimingCollector', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { rafCallback = null }) - vi.spyOn(performance, 'now').mockReturnValue(0) vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) collector = new FrameTimingCollector() @@ -66,6 +80,23 @@ describe('FrameTimingCollector', () => { expect(visibilityListeners).toHaveLength(1) expect(window.cancelAnimationFrame).toHaveBeenCalledOnce() }) + + it('recalibrates after resuming without clearing accumulated counts', () => { + collector.start() + runSteadyFrames(60, 8) + expect(collector.getMetrics().estimatedRefreshRate).toBe(60) + + collector.stop() + collector.start() + + const resumedMetrics = collector.getMetrics() + expect(resumedMetrics.estimatedRefreshRate).toBeNull() + expect(resumedMetrics.observedFrameIntervals).toBe(8) + + runSteadyFrames(120, 8, 1_000) + expect(collector.getMetrics().estimatedRefreshRate).toBe(120) + expect(collector.getMetrics().observedFrameIntervals).toBe(16) + }) }) describe('reset', () => { @@ -73,17 +104,17 @@ describe('FrameTimingCollector', () => { collector.start() // Simulate some frames - vi.spyOn(performance, 'now').mockReturnValue(10) - rafCallback?.(10) - vi.spyOn(performance, 'now').mockReturnValue(26.67) - rafCallback?.(26.67) + runFrame(10) + runFrame(26.67) collector.reset() const metrics = collector.getMetrics() expect(metrics.frameTimes).toEqual([]) expect(metrics.maxFrameTime).toBe(0) - expect(metrics.droppedFrames).toBe(0) + expect(metrics.inferredDroppedFrames).toBe(0) + expect(metrics.observedFrameIntervals).toBe(0) + expect(metrics.excludedFrameIntervals).toBe(0) expect(metrics.frameJitter).toBe(0) }) }) @@ -93,7 +124,11 @@ describe('FrameTimingCollector', () => { const metrics = collector.getMetrics() expect(metrics.frameTimes).toEqual([]) expect(metrics.maxFrameTime).toBe(0) - expect(metrics.droppedFrames).toBe(0) + expect(metrics.estimatedRefreshRate).toBeNull() + expect(metrics.frameBudget).toBeNull() + expect(metrics.observedFrameIntervals).toBe(0) + expect(metrics.inferredDroppedFrames).toBe(0) + expect(metrics.excludedFrameIntervals).toBe(0) expect(metrics.frameJitter).toBe(0) expect(metrics.frameStability).toBe(100) }) @@ -102,12 +137,10 @@ describe('FrameTimingCollector', () => { collector.start() // The first callback establishes a baseline; the second records a frame. - vi.spyOn(performance, 'now').mockReturnValue(10) - rafCallback?.(10) + runFrame(10) expect(collector.getMetrics().frameTimes).toEqual([]) - vi.spyOn(performance, 'now').mockReturnValue(26.67) - rafCallback?.(26.67) + runFrame(26.67) const metrics = collector.getMetrics() expect(metrics.frameTimes).toHaveLength(1) @@ -117,48 +150,78 @@ describe('FrameTimingCollector', () => { it('tracks max frame time', () => { collector.start() - vi.spyOn(performance, 'now').mockReturnValue(10) - rafCallback?.(10) - vi.spyOn(performance, 'now').mockReturnValue(60) - rafCallback?.(60) + runFrame(10) + runFrame(60) const metrics = collector.getMetrics() expect(metrics.maxFrameTime).toBe(50) }) - it('counts dropped frames for long frames', () => { + it.each([60, 120, 144])('calibrates a %i Hz budget and infers missed refreshes', refreshRate => { + collector.start() + + const timestamp = runSteadyFrames(refreshRate, 8) + const interval = 1000 / refreshRate + let metrics = collector.getMetrics() + + expect(metrics.estimatedRefreshRate).toBe(refreshRate) + expect(metrics.frameBudget).toBeCloseTo(interval, 2) + expect(metrics.observedFrameIntervals).toBe(8) + expect(metrics.inferredDroppedFrames).toBe(0) + + runFrame(timestamp + interval * 3) + metrics = collector.getMetrics() + + expect(metrics.observedFrameIntervals).toBe(9) + expect(metrics.inferredDroppedFrames).toBe(2) + }) + + it('counts only complete missed refresh opportunities', () => { + collector.start() + + let timestamp = runSteadyFrames(120, 8) + const interval = 1000 / 120 + + timestamp += interval * 1.5 + runFrame(timestamp) + expect(collector.getMetrics().inferredDroppedFrames).toBe(0) + + timestamp += interval * 2.5 + runFrame(timestamp) + expect(collector.getMetrics().inferredDroppedFrames).toBe(1) + }) + + it('excludes throttled iframe gaps and recalibrates', () => { collector.start() - // Frame time of 50ms = should count as 2 dropped frames (50/16.67 - 1 ≈ 2) - vi.spyOn(performance, 'now').mockReturnValue(10) - rafCallback?.(10) - vi.spyOn(performance, 'now').mockReturnValue(60) - rafCallback?.(60) + const timestamp = runSteadyFrames(60, 8) + runFrame(timestamp + 1_000) const metrics = collector.getMetrics() - expect(metrics.droppedFrames).toBeGreaterThan(0) + expect(metrics.observedFrameIntervals).toBe(8) + expect(metrics.inferredDroppedFrames).toBe(0) + expect(metrics.excludedFrameIntervals).toBe(1) + expect(metrics.estimatedRefreshRate).toBeNull() + expect(metrics.frameBudget).toBeNull() }) it('starts a fresh baseline after the document becomes visible', () => { collector.start() - vi.spyOn(performance, 'now').mockReturnValue(10) - rafCallback?.(10) + runFrame(10) hidden = true document.dispatchEvent(new Event('visibilitychange')) hidden = false document.dispatchEvent(new Event('visibilitychange')) - vi.spyOn(performance, 'now').mockReturnValue(1_000) - rafCallback?.(1_000) + runFrame(1_000) expect(collector.getMetrics().frameTimes).toEqual([]) - vi.spyOn(performance, 'now').mockReturnValue(1_016.67) - rafCallback?.(1_016.67) + runFrame(1_016.67) expect(collector.getMetrics().frameTimes[0]).toBeCloseTo(16.67, 1) - expect(collector.getMetrics().droppedFrames).toBe(0) + expect(collector.getMetrics().inferredDroppedFrames).toBe(0) }) }) @@ -168,10 +231,8 @@ describe('FrameTimingCollector', () => { collector = new FrameTimingCollector(onFrame) collector.start() - vi.spyOn(performance, 'now').mockReturnValue(10) - rafCallback?.(10) - vi.spyOn(performance, 'now').mockReturnValue(26.67) - rafCallback?.(26.67) + runFrame(10) + runFrame(26.67) expect(onFrame).toHaveBeenCalledWith(expect.closeTo(16.67, 1)) }) 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 8cfccdb..a63ad1b 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 @@ -128,6 +128,7 @@ describe('DEFAULT_METRICS', () => { it('initializes corrected metric aliases', () => { const deprecatedMetrics = DEFAULT_METRICS as unknown as Record + expect(DEFAULT_METRICS.inferredDroppedFrames).toBe(deprecatedMetrics.droppedFrames) expect(DEFAULT_METRICS.pointerFrameInterval).toBe(deprecatedMetrics.paintTime) expect(DEFAULT_METRICS.maxPointerFrameInterval).toBe(deprecatedMetrics.maxPaintTime) expect(DEFAULT_METRICS.pointerFrameJitter).toBe(deprecatedMetrics.paintJitter) @@ -155,6 +156,8 @@ describe('PERFORMANCE_METRIC_METADATA', () => { unit: 'per-second', }) expect(PERFORMANCE_METRIC_METADATA.layerPromotionCandidates.quality).toBe('low') + expect(PERFORMANCE_METRIC_METADATA.estimatedRefreshRate.unit).toBe('hertz') + expect(PERFORMANCE_METRIC_METADATA.inferredDroppedFrames.provenance).toBe('heuristic') expect(PERFORMANCE_METRIC_METADATA.eventListenerCount.quality).toBe('unavailable') expect(PERFORMANCE_METRIC_METADATA.observerCount.quality).toBe('unavailable') }) diff --git a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts index 8a37bab..b7b9a08 100644 --- a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts +++ b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts @@ -307,6 +307,11 @@ export class CollectorManager { fps, frameTime: Math.round(avgFrameTime * 10) / 10, maxFrameTime: Math.round(frame.maxFrameTime * 10) / 10, + estimatedRefreshRate: frame.estimatedRefreshRate, + frameBudget: frame.frameBudget === null ? null : Math.round(frame.frameBudget * 100) / 100, + observedFrameIntervals: frame.observedFrameIntervals, + inferredDroppedFrames: frame.inferredDroppedFrames, + excludedFrameIntervals: frame.excludedFrameIntervals, inputLatency: Math.round(avgInputLatency * 10) / 10, maxInputLatency: Math.round(input.maxInputLatency * 10) / 10, pointerFrameInterval, @@ -337,7 +342,7 @@ export class CollectorManager { lastLoaf: loaf.lastLoaf, worstLoaf: loaf.worstLoaf, // Continue with other metrics - droppedFrames: frame.droppedFrames, + droppedFrames: frame.inferredDroppedFrames, frameJitter: frame.frameJitter, frameStability: frame.frameStability, styleWrites: style.styleWrites, diff --git a/packages/storybook-addon-performance-panel/collectors/constants.ts b/packages/storybook-addon-performance-panel/collectors/constants.ts index ff200ce..ab6584f 100644 --- a/packages/storybook-addon-performance-panel/collectors/constants.ts +++ b/packages/storybook-addon-performance-panel/collectors/constants.ts @@ -7,11 +7,20 @@ // Frame Timing Constants // ============================================================================ -/** Target frame time for 60fps rendering (ms) */ -export const FRAME_TIME_60FPS = 16.67 +/** Number of stable RAF intervals required before estimating the display refresh rate */ +export const FRAME_RATE_CALIBRATION_SAMPLES = 8 -/** Multiplier for detecting dropped frames (frame > 16.67ms × 2 = dropped) */ -export const DROPPED_FRAME_MULTIPLIER = 2 +/** Rolling window used to adapt when the story moves between displays */ +export const FRAME_RATE_CALIBRATION_WINDOW = 30 + +/** RAF gaps at or above this duration are treated as inactive/throttled iframe periods */ +export const FRAME_INACTIVE_GAP_MS = 250 + +/** Fastest plausible display interval used during refresh-rate calibration */ +export const FRAME_INTERVAL_MIN_MS = 1000 / 300 + +/** Slowest plausible display interval used during refresh-rate calibration */ +export const FRAME_INTERVAL_MAX_MS = 1000 / 24 /** Frame time threshold (ms) for detecting layout thrashing */ export const THRASHING_FRAME_THRESHOLD = 50 diff --git a/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts b/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts index 7ae8189..880d874 100644 --- a/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts @@ -4,8 +4,11 @@ */ import { - DROPPED_FRAME_MULTIPLIER, - FRAME_TIME_60FPS, + FRAME_INACTIVE_GAP_MS, + FRAME_INTERVAL_MAX_MS, + FRAME_INTERVAL_MIN_MS, + FRAME_RATE_CALIBRATION_SAMPLES, + FRAME_RATE_CALIBRATION_WINDOW, FRAME_TIMES_WINDOW, JITTER_BASELINE_SIZE, JITTER_FRAME_ABSOLUTE, @@ -17,10 +20,16 @@ import { import type {MetricCollector} from './types' import {addToWindow, computeAverage, computeFrameStability, updateMaxWithDecay} from './utils' +const FRAME_RATIO_EPSILON = 0.01 + export interface FrameTimingMetrics { frameTimes: number[] maxFrameTime: number - droppedFrames: number + estimatedRefreshRate: number | null + frameBudget: number | null + observedFrameIntervals: number + inferredDroppedFrames: number + excludedFrameIntervals: number frameJitter: number /** Frame time stability (0-100%). 100% = perfectly consistent, lower = choppy */ frameStability: number @@ -31,16 +40,23 @@ export interface FrameTimingMetrics { * * Tracks: * - Frame duration via RAF delta - * - Dropped frames (>2× budget) + * - Display refresh rate and frame budget estimated from stable RAF intervals + * - Inferred dropped frames kept separate from observed RAF intervals + * - Inactive or throttled iframe gaps excluded from frame metrics * - Max frame time with decay * - Frame jitter (sudden spikes) */ export class FrameTimingCollector implements MetricCollector { #frameTimes: number[] = [] #maxFrameTime = 0 - #droppedFrames = 0 + #calibrationIntervals: number[] = [] + #estimatedRefreshRate: number | null = null + #frameBudget: number | null = null + #observedFrameIntervals = 0 + #inferredDroppedFrames = 0 + #excludedFrameIntervals = 0 #frameJitter = 0 - #lastTime = 0 + #lastTime: number | null = null #animationId: number | null = null #onFrame?: (delta: number) => void #running = false @@ -53,7 +69,8 @@ export class FrameTimingCollector implements MetricCollector if (this.#running) return this.#running = true - this.#lastTime = 0 + this.#lastTime = null + this.#resetCalibration() document.addEventListener('visibilitychange', this.#handleVisibilityChange) if (!document.hidden) { this.#animationId = requestAnimationFrame(this.#measure) @@ -69,45 +86,59 @@ export class FrameTimingCollector implements MetricCollector cancelAnimationFrame(this.#animationId) this.#animationId = null } - this.#lastTime = 0 + this.#lastTime = null } reset(): void { this.#frameTimes = [] this.#maxFrameTime = 0 - this.#droppedFrames = 0 + this.#calibrationIntervals = [] + this.#estimatedRefreshRate = null + this.#frameBudget = null + this.#observedFrameIntervals = 0 + this.#inferredDroppedFrames = 0 + this.#excludedFrameIntervals = 0 this.#frameJitter = 0 - this.#lastTime = 0 + this.#lastTime = null } getMetrics(): FrameTimingMetrics { return { frameTimes: this.#frameTimes, maxFrameTime: this.#maxFrameTime, - droppedFrames: this.#droppedFrames, + estimatedRefreshRate: this.#estimatedRefreshRate, + frameBudget: this.#frameBudget, + observedFrameIntervals: this.#observedFrameIntervals, + inferredDroppedFrames: this.#inferredDroppedFrames, + excludedFrameIntervals: this.#excludedFrameIntervals, frameJitter: this.#frameJitter, frameStability: computeFrameStability(this.#frameTimes), } } - #measure = (): void => { + #measure = (timestamp: DOMHighResTimeStamp): void => { this.#animationId = null if (!this.#running || document.hidden) return - const now = performance.now() - if (this.#lastTime > 0) { - const delta = now - this.#lastTime + if (this.#lastTime !== null) { + const delta = timestamp - this.#lastTime - this.#processFrame(delta) - this.#onFrame?.(delta) + if (delta >= FRAME_INACTIVE_GAP_MS) { + this.#excludedFrameIntervals++ + this.#resetCalibration() + } else if (delta > 0) { + this.#processFrame(delta) + this.#onFrame?.(delta) + } } - this.#lastTime = now + this.#lastTime = timestamp this.#animationId = requestAnimationFrame(this.#measure) } #handleVisibilityChange = (): void => { - this.#lastTime = 0 + this.#lastTime = null + this.#resetCalibration() if (document.hidden) { if (this.#animationId !== null) { @@ -120,15 +151,29 @@ export class FrameTimingCollector implements MetricCollector } #processFrame(delta: number): void { + const hadFrameBudget = this.#frameBudget !== null + this.#observedFrameIntervals++ + // Add to rolling window addToWindow(this.#frameTimes, delta, FRAME_TIMES_WINDOW) // Update max with decay this.#maxFrameTime = updateMaxWithDecay(this.#maxFrameTime, delta, MAX_DECAY_THRESHOLD, MAX_DECAY_RATE) - // Dropped frames - if (delta > FRAME_TIME_60FPS * DROPPED_FRAME_MULTIPLIER) { - this.#droppedFrames += Math.floor(delta / FRAME_TIME_60FPS) - 1 + if (delta >= FRAME_INTERVAL_MIN_MS && delta <= FRAME_INTERVAL_MAX_MS) { + addToWindow(this.#calibrationIntervals, delta, FRAME_RATE_CALIBRATION_WINDOW) + this.#updateFrameBudget() + } + + if (this.#frameBudget !== null) { + if (hadFrameBudget) { + this.#inferredDroppedFrames += this.#inferDroppedFrames(delta) + } else { + this.#inferredDroppedFrames += this.#calibrationIntervals.reduce( + (total, interval) => total + this.#inferDroppedFrames(interval), + 0, + ) + } } // Frame jitter detection @@ -142,4 +187,28 @@ export class FrameTimingCollector implements MetricCollector if (isJitter) this.#frameJitter++ } } + + #updateFrameBudget(): void { + if (this.#calibrationIntervals.length < FRAME_RATE_CALIBRATION_SAMPLES) return + + const sortedIntervals = [...this.#calibrationIntervals].sort((a, b) => a - b) + const lowerQuartileIndex = Math.floor((sortedIntervals.length - 1) * 0.25) + const interval = sortedIntervals[lowerQuartileIndex] + if (interval === undefined) return + + this.#estimatedRefreshRate = Math.round(1000 / interval) + this.#frameBudget = 1000 / this.#estimatedRefreshRate + } + + #inferDroppedFrames(delta: number): number { + if (this.#frameBudget === null) return 0 + const completeRefreshIntervals = Math.floor(delta / this.#frameBudget + FRAME_RATIO_EPSILON) + return Math.max(0, completeRefreshIntervals - 1) + } + + #resetCalibration(): void { + this.#calibrationIntervals = [] + this.#estimatedRefreshRate = null + this.#frameBudget = null + } } diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index 342f5b6..ba10aae 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -354,7 +354,17 @@ export interface PerformanceMetrics { frameTime: number /** Peak frame time with decay (ms). Spikes indicate jank */ maxFrameTime: number - /** Frames exceeding 2× frame budget (33.34ms at 60fps) */ + /** Refresh rate estimated from stable requestAnimationFrame intervals (Hz) */ + estimatedRefreshRate: number | null + /** Frame budget derived from the estimated refresh rate (ms) */ + frameBudget: number | null + /** Number of valid consecutive requestAnimationFrame intervals observed */ + observedFrameIntervals: number + /** Missed refresh opportunities inferred from observed intervals and the calibrated frame budget */ + inferredDroppedFrames: number + /** Long intervals excluded because the document or iframe appeared inactive/throttled */ + excludedFrameIntervals: number + /** @deprecated Use inferredDroppedFrames. This value is inferred rather than directly observed. */ droppedFrames: number /** Frame jitter count - sudden spikes in frame time vs baseline */ frameJitter: number @@ -551,6 +561,7 @@ export type MetricUnit = | 'boolean' | 'count' | 'frames-per-second' + | 'hertz' | 'megabytes' | 'megabytes-per-second' | 'milliseconds' @@ -575,7 +586,12 @@ 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'}, + estimatedRefreshRate: {provenance: 'heuristic', quality: 'medium', unit: 'hertz'}, + frameBudget: {provenance: 'derived', quality: 'medium', unit: 'milliseconds'}, + observedFrameIntervals: {provenance: 'native', quality: 'high', unit: 'count'}, + inferredDroppedFrames: {provenance: 'heuristic', quality: 'medium', unit: 'count'}, + excludedFrameIntervals: {provenance: 'heuristic', quality: 'medium', unit: 'count'}, + droppedFrames: {provenance: 'heuristic', 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'}, @@ -655,6 +671,11 @@ export const DEFAULT_METRICS: PerformanceMetrics = { fps: 0, frameTime: 0, maxFrameTime: 0, + estimatedRefreshRate: null, + frameBudget: null, + observedFrameIntervals: 0, + inferredDroppedFrames: 0, + excludedFrameIntervals: 0, droppedFrames: 0, frameJitter: 0, frameStability: 100, diff --git a/packages/storybook-addon-performance-panel/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx index c9f8e66..91faa8c 100644 --- a/packages/storybook-addon-performance-panel/performance-panel.tsx +++ b/packages/storybook-addon-performance-panel/performance-panel.tsx @@ -339,7 +339,7 @@ const MetricsSection = React.memo(function MetricsSection({icon, title, children * Displays: * - FPS: Frames per second with sparkline trend * - Frame Time: Average/max frame duration - * - Dropped Frames: Count of frames exceeding 2× budget + * - Inferred Drops: Missed refresh opportunities inferred from a calibrated frame budget * - Frame Jitter: Sudden spikes in frame time * * @component @@ -351,7 +351,11 @@ type FrameTimingSectionProps = Pick< | 'frameTime' | 'maxFrameTime' | 'frameTimeHistory' - | 'droppedFrames' + | 'estimatedRefreshRate' + | 'frameBudget' + | 'observedFrameIntervals' + | 'inferredDroppedFrames' + | 'excludedFrameIntervals' | 'frameJitter' | 'frameStability' | 'pointerFrameInterval' @@ -365,7 +369,11 @@ const FrameTimingSection = React.memo(function FrameTimingSection({ frameTime, maxFrameTime, frameTimeHistory, - droppedFrames, + estimatedRefreshRate, + frameBudget, + observedFrameIntervals, + inferredDroppedFrames, + excludedFrameIntervals, frameJitter, frameStability, pointerFrameInterval, @@ -374,7 +382,11 @@ const FrameTimingSection = React.memo(function FrameTimingSection({ }: FrameTimingSectionProps) { const fpsStatus = getStatus(fps, THRESHOLDS.FPS_GOOD, THRESHOLDS.FPS_WARNING, true) const droppedStatus = - droppedFrames > THRESHOLDS.DROPPED_FRAMES_WARNING ? 'error' : droppedFrames > 0 ? 'warning' : 'success' + inferredDroppedFrames > THRESHOLDS.DROPPED_FRAMES_WARNING + ? 'error' + : inferredDroppedFrames > 0 + ? 'warning' + : 'success' const frameJitterStatus = getZeroStatus(frameJitter) const stabilityStatus = frameStability >= 90 ? 'success' : frameStability >= 70 ? 'warning' : 'error' const pointerFrameJitterStatus = getZeroStatus(pointerFrameJitter) @@ -398,7 +410,7 @@ const FrameTimingSection = React.memo(function FrameTimingSection({ } - detail={<>max {formatMs(maxFrameTime)}} + detail={ + <> + max {formatMs(maxFrameTime)} ·{' '} + {estimatedRefreshRate === null || frameBudget === null + ? 'calibrating refresh rate' + : `${String(estimatedRefreshRate)} Hz estimate · ${formatMs(frameBudget)} budget`} + + } > {formatMs(frameTime)} - + + {observedFrameIntervals} observed + {excludedFrameIntervals > 0 ? ` · ${String(excludedFrameIntervals)} excluded` : ''} + + } + > - {droppedFrames} - {droppedFrames === 0 ? : 💧} + {inferredDroppedFrames} + {inferredDroppedFrames === 0 ? : 💧} @@ -1622,7 +1650,11 @@ function ConnectedPanelContent({storyId}: {storyId: string}) { frameTime={metrics.frameTime} maxFrameTime={metrics.maxFrameTime} frameTimeHistory={metrics.frameTimeHistory} - droppedFrames={metrics.droppedFrames} + estimatedRefreshRate={metrics.estimatedRefreshRate} + frameBudget={metrics.frameBudget} + observedFrameIntervals={metrics.observedFrameIntervals} + inferredDroppedFrames={metrics.inferredDroppedFrames} + excludedFrameIntervals={metrics.excludedFrameIntervals} frameJitter={metrics.frameJitter} frameStability={metrics.frameStability} pointerFrameInterval={metrics.pointerFrameInterval} diff --git a/packages/storybook-addon-performance-panel/react/performance-decorator.tsx b/packages/storybook-addon-performance-panel/react/performance-decorator.tsx index 059a292..4b1f7ec 100644 --- a/packages/storybook-addon-performance-panel/react/performance-decorator.tsx +++ b/packages/storybook-addon-performance-panel/react/performance-decorator.tsx @@ -36,8 +36,8 @@ * * ### Frame Timing * - **FPS**: Frames per second calculated from requestAnimationFrame deltas - * - **Frame Time**: Average milliseconds per frame (target: ≤16.67ms for 60fps) - * - **Dropped Frames**: Frames exceeding 2× the expected frame time + * - **Frame Time**: Average RAF interval with a refresh-rate-aware budget + * - **Inferred Drops**: Missed refresh opportunities inferred from observed intervals * * ### Input Responsiveness * - **Input Latency**: Time from pointer event to next animation frame