Skip to content
Merged
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/frame-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@github-ui/storybook-addon-performance-panel': patch
---

Avoid synthetic first-frame samples and pause frame collection while the preview document is hidden.
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,11 @@ describe('CollectorManager', () => {
it('returns updated metrics after collecting', () => {
manager.start()

// Simulate a frame
vi.spyOn(performance, 'now').mockReturnValue(16.67)
rafCallback?.(16.67)
// Seed the frame baseline, then simulate a measured frame.
vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(10)
vi.spyOn(performance, 'now').mockReturnValue(26.67)
rafCallback?.(26.67)

const metrics = manager.getFrameMetrics()
expect(metrics.frameTimes.length).toBeGreaterThan(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe('FrameTimingCollector', () => {
let collector: FrameTimingCollector
let rafCallback: FrameRequestCallback | null = null
let rafId = 0
let hidden = false

beforeEach(() => {
// Mock requestAnimationFrame
Expand All @@ -17,6 +18,7 @@ describe('FrameTimingCollector', () => {
rafCallback = null
})
vi.spyOn(performance, 'now').mockReturnValue(0)
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden)

collector = new FrameTimingCollector()
})
Expand All @@ -31,6 +33,17 @@ describe('FrameTimingCollector', () => {
collector.start()
expect(window.requestAnimationFrame).toHaveBeenCalled()
})

it('does not schedule duplicate work when started repeatedly', () => {
const addEventListener = vi.spyOn(document, 'addEventListener')

collector.start()
collector.start()

const visibilityListeners = addEventListener.mock.calls.filter(([eventName]) => eventName === 'visibilitychange')
expect(visibilityListeners).toHaveLength(1)
expect(window.requestAnimationFrame).toHaveBeenCalledOnce()
})
})

describe('stop', () => {
Expand All @@ -39,17 +52,31 @@ describe('FrameTimingCollector', () => {
collector.stop()
expect(window.cancelAnimationFrame).toHaveBeenCalled()
})

it('does not clean up more than once when stopped repeatedly', () => {
const removeEventListener = vi.spyOn(document, 'removeEventListener')

collector.start()
collector.stop()
collector.stop()

const visibilityListeners = removeEventListener.mock.calls.filter(
([eventName]) => eventName === 'visibilitychange',
)
expect(visibilityListeners).toHaveLength(1)
expect(window.cancelAnimationFrame).toHaveBeenCalledOnce()
})
})

describe('reset', () => {
it('clears all metrics', () => {
collector.start()

// Simulate some frames
vi.spyOn(performance, 'now').mockReturnValue(16.67)
rafCallback?.(16.67)
vi.spyOn(performance, 'now').mockReturnValue(33.34)
rafCallback?.(33.34)
vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(10)
vi.spyOn(performance, 'now').mockReturnValue(26.67)
rafCallback?.(26.67)

collector.reset()

Expand All @@ -74,22 +101,26 @@ describe('FrameTimingCollector', () => {
it('tracks frame times', () => {
collector.start()

// Simulate 60fps frame (16.67ms)
vi.spyOn(performance, 'now').mockReturnValue(16.67)
rafCallback?.(16.67)
// The first callback establishes a baseline; the second records a frame.
vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(10)
expect(collector.getMetrics().frameTimes).toEqual([])

vi.spyOn(performance, 'now').mockReturnValue(26.67)
rafCallback?.(26.67)

const metrics = collector.getMetrics()
// At least one frame should be tracked (start() may record initial frame too)
expect(metrics.frameTimes.length).toBeGreaterThanOrEqual(1)
// The last frame should be close to 16.67ms
expect(metrics.frameTimes[metrics.frameTimes.length - 1]).toBeCloseTo(16.67, 1)
expect(metrics.frameTimes).toHaveLength(1)
expect(metrics.frameTimes[0]).toBeCloseTo(16.67, 1)
})

it('tracks max frame time', () => {
collector.start()

vi.spyOn(performance, 'now').mockReturnValue(50)
rafCallback?.(50)
vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(10)
vi.spyOn(performance, 'now').mockReturnValue(60)
rafCallback?.(60)

const metrics = collector.getMetrics()
expect(metrics.maxFrameTime).toBe(50)
Expand All @@ -99,12 +130,36 @@ describe('FrameTimingCollector', () => {
collector.start()

// Frame time of 50ms = should count as 2 dropped frames (50/16.67 - 1 ≈ 2)
vi.spyOn(performance, 'now').mockReturnValue(50)
rafCallback?.(50)
vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(10)
vi.spyOn(performance, 'now').mockReturnValue(60)
rafCallback?.(60)

const metrics = collector.getMetrics()
expect(metrics.droppedFrames).toBeGreaterThan(0)
})

it('starts a fresh baseline after the document becomes visible', () => {
collector.start()

vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(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)
expect(collector.getMetrics().frameTimes).toEqual([])

vi.spyOn(performance, 'now').mockReturnValue(1_016.67)
rafCallback?.(1_016.67)

expect(collector.getMetrics().frameTimes[0]).toBeCloseTo(16.67, 1)
expect(collector.getMetrics().droppedFrames).toBe(0)
})
})

describe('onFrame callback', () => {
Expand All @@ -113,8 +168,10 @@ describe('FrameTimingCollector', () => {
collector = new FrameTimingCollector(onFrame)
collector.start()

vi.spyOn(performance, 'now').mockReturnValue(16.67)
rafCallback?.(16.67)
vi.spyOn(performance, 'now').mockReturnValue(10)
rafCallback?.(10)
vi.spyOn(performance, 'now').mockReturnValue(26.67)
rafCallback?.(26.67)

expect(onFrame).toHaveBeenCalledWith(expect.closeTo(16.67, 1))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,41 @@ export class FrameTimingCollector implements MetricCollector<FrameTimingMetrics>
#lastTime = 0
#animationId: number | null = null
#onFrame?: (delta: number) => void
#running = false

constructor(onFrame?: (delta: number) => void) {
this.#onFrame = onFrame
}

start(): void {
this.#lastTime = performance.now()
this.#measure()
if (this.#running) return

this.#running = true
this.#lastTime = 0
document.addEventListener('visibilitychange', this.#handleVisibilityChange)
if (!document.hidden) {
this.#animationId = requestAnimationFrame(this.#measure)
}
}

stop(): void {
if (!this.#running) return

this.#running = false
document.removeEventListener('visibilitychange', this.#handleVisibilityChange)
if (this.#animationId !== null) {
cancelAnimationFrame(this.#animationId)
this.#animationId = null
}
this.#lastTime = 0
}

reset(): void {
this.#frameTimes = []
this.#maxFrameTime = 0
this.#droppedFrames = 0
this.#frameJitter = 0
this.#lastTime = 0
}

getMetrics(): FrameTimingMetrics {
Expand All @@ -78,16 +91,34 @@ export class FrameTimingCollector implements MetricCollector<FrameTimingMetrics>
}

#measure = (): void => {
this.#animationId = null
if (!this.#running || document.hidden) return

const now = performance.now()
const delta = now - this.#lastTime
this.#lastTime = now
if (this.#lastTime > 0) {
const delta = now - this.#lastTime

this.#processFrame(delta)
this.#onFrame?.(delta)
this.#processFrame(delta)
this.#onFrame?.(delta)
}
this.#lastTime = now

this.#animationId = requestAnimationFrame(this.#measure)
}

#handleVisibilityChange = (): void => {
this.#lastTime = 0

if (document.hidden) {
if (this.#animationId !== null) {
cancelAnimationFrame(this.#animationId)
this.#animationId = null
}
} else if (this.#running && this.#animationId === null) {
this.#animationId = requestAnimationFrame(this.#measure)
}
}

#processFrame(delta: number): void {
// Add to rolling window
addToWindow(this.#frameTimes, delta, FRAME_TIMES_WINDOW)
Expand Down