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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/improve-frame-fidelity.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions packages/storybook-addon-performance-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
16 changes: 10 additions & 6 deletions packages/storybook-addon-performance-panel/collectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%)

Expand All @@ -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)
}
Expand All @@ -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

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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()
Expand Down Expand Up @@ -66,24 +80,41 @@ 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', () => {
it('clears all metrics', () => {
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)
})
})
Expand All @@ -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)
})
Expand All @@ -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)
Expand All @@ -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)
})
})

Expand All @@ -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))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe('DEFAULT_METRICS', () => {

it('initializes corrected metric aliases', () => {
const deprecatedMetrics = DEFAULT_METRICS as unknown as Record<string, unknown>
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)
Expand Down Expand Up @@ -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')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading