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/add-native-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@github-ui/storybook-addon-performance-panel': minor
---

Add bounded native attribution for layout-shift sources, forced style and layout work, Element Timing raw timestamps and URLs, and script Resource Timing URLs and initiators.
4 changes: 4 additions & 0 deletions packages/storybook-addon-performance-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,21 @@ The addon consists of two main parts:
- **P95 Duration**: 95th percentile LoAF duration
- **Script Attribution**: Which scripts contributed to long frames
- Source URL, function name, invoker type (event-listener, user-callback, etc.)
- Forced style and layout duration for the frame and top script
- Helps identify exactly which code caused slow frames

### Element Timing
- **Element Count**: Number of elements with `elementtiming` attribute tracked
- **Largest Render Time**: Slowest element to render (similar to LCP concept)
- **Individual Elements**: Render time for each tracked element
- Preserves relative story timing, raw Performance Timeline timestamps, selectors, and resource URLs
- Add `elementtiming="identifier"` attribute to elements you want to track
- Useful for measuring when hero images, key content, or specific UI elements render
- Only *timing‑eligible* elements produce entries. The browser will ignore arbitrary custom elements or nodes inside shadow DOM. Valid targets include images (`<img>`), SVG `<image>`s, videos with poster frames, elements with contentful `background-image`s, and text nodes. See the [Element Timing spec](https://w3c.github.io/paint-timing/#timing-eligible) for details.

### Layout Stability
- **CLS**: Cumulative Layout Shift score (Core Web Vital)
- **Shift Sources**: Bounded selectors and geometry for recent native layout-shift attribution
- **Forced Reflows**: Layout property reads after style writes
- **Style Writes**: Inline style mutations observed via MutationObserver

Expand All @@ -124,6 +127,7 @@ The addon consists of two main parts:
- **GC Pressure**: Memory allocation rate (MB/s)
- **Initial Paint Milestones**: Native first-paint and first-contentful-paint entries
- **Script Resource Load Time**: Cumulative loading duration derived from script Resource Timing entries
- **Script Resource Attribution**: Bounded slowest-resource URLs, initiator types, relative start times, and durations
- **Layer-Promotion Candidates**: Elements matching CSS layer-promotion heuristics (not the browser's compositor layer count)

## Metric Thresholds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ describe('PerformancePanel visibility', () => {
frameBudget: 8.33,
observedFrameIntervals: 20,
inferredDroppedFrames: 2,
layoutShiftScore: 0.05,
layoutShiftCount: 1,
layoutShiftAttribution: [
{
startTime: 10,
score: 0.05,
sources: [
{
selector: '#shifted-card',
previousRect: {x: 0, y: 0, width: 100, height: 20},
currentRect: {x: 0, y: 10, width: 100, height: 20},
},
],
},
],
scriptResourceLoadTime: 42,
scriptResourceCount: 1,
scriptResources: [{url: '/assets/story.js', initiatorType: 'script', startTime: 5, duration: 42}],
})

await expect.poll(() => document.body.textContent).toContain('Pointer Frame Interval')
Expand All @@ -97,5 +115,7 @@ describe('PerformancePanel visibility', () => {
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')
await expect.poll(() => document.body.textContent).toContain('Latest Shift Source')
await expect.poll(() => document.body.textContent).toContain('Script Resources')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {describe, expect, it} from 'vitest'

import {addBoundedAttribution, ATTRIBUTION_ENTRY_LIMIT, limitAttributionString} from '../attribution'

describe('attribution bounds', () => {
it('retains only the most recent bounded entries', () => {
const entries: number[] = []
for (let value = 0; value < ATTRIBUTION_ENTRY_LIMIT + 5; value++) {
addBoundedAttribution(entries, value)
}

expect(entries).toHaveLength(ATTRIBUTION_ENTRY_LIMIT)
expect(entries[0]).toBe(5)
expect(entries.at(-1)).toBe(ATTRIBUTION_ENTRY_LIMIT + 4)
})

it('caps attribution strings', () => {
expect(limitAttributionString('abcdef', 'unknown', 4)).toBe('abcd')
expect(limitAttributionString('', 'unknown', 4)).toBe('unkn')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ describe('CollectorManager', () => {
expect(metrics).toHaveProperty('layoutShiftScore')
expect(metrics).toHaveProperty('layoutShiftCount')
expect(metrics).toHaveProperty('currentSessionCLS')
expect(metrics).toHaveProperty('layoutShiftAttribution')

// React metrics
expect(metrics).toHaveProperty('reactMountCount')
Expand All @@ -317,6 +318,7 @@ describe('CollectorManager', () => {
expect(metrics).toHaveProperty('elementTimingSupported')
expect(metrics).toHaveProperty('elementTimingCount')
expect(metrics).toHaveProperty('elementTimings')
expect(metrics).toHaveProperty('scriptResources')
})

it('uses setDomElementCount to update domElements', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'

import {ElementTimingCollector} from '../../collectors/element-timing-collector'
import {ATTRIBUTION_ENTRY_LIMIT} from '../attribution'

describe('ElementTimingCollector', () => {
let collector: ElementTimingCollector
Expand Down Expand Up @@ -116,6 +117,15 @@ describe('ElementTimingCollector', () => {
naturalHeight: 0,
url: '',
} as unknown as PerformanceEntry,
{
renderTime: 0,
loadTime: 1_050,
identifier: 'poster',
element: null,
naturalWidth: 0,
naturalHeight: 0,
url: '',
} as unknown as PerformanceEntry,
],
getEntriesByName: () => [],
getEntriesByType: () => [],
Expand All @@ -134,9 +144,66 @@ describe('ElementTimingCollector', () => {

expect(scopedCollector.getMetrics()).toMatchObject({
largestRenderTime: 125,
elements: [{identifier: 'hero', renderTime: 125, loadTime: 100}],
elements: [
{
identifier: 'hero',
renderTime: 125,
rawRenderTime: 1_125,
loadTime: 100,
rawLoadTime: 1_100,
},
{
identifier: 'poster',
renderTime: 50,
rawRenderTime: 0,
loadTime: 50,
rawLoadTime: 1_050,
},
],
})
scopedCollector.stop()
nowSpy.mockRestore()
})

it('bounds retained records without undercounting observed elements', () => {
const observerCallbacks: PerformanceObserverCallback[] = []
vi.stubGlobal(
'PerformanceObserver',
class MockPerformanceObserver {
static supportedEntryTypes = ['element']
constructor(callback: PerformanceObserverCallback) {
observerCallbacks.push(callback)
}
observe() {
/* empty */
}
disconnect() {
/* empty */
}
},
)
vi.spyOn(performance, 'now').mockReturnValue(1_000)
const scopedCollector = new ElementTimingCollector()
scopedCollector.start()
const entries = Array.from({length: ATTRIBUTION_ENTRY_LIMIT + 5}, (_, index) => ({
renderTime: 1_001 + index,
loadTime: 0,
identifier: `element-${String(index)}`,
element: null,
naturalWidth: 0,
naturalHeight: 0,
url: '',
}))

observerCallbacks[0]?.(
{getEntries: () => entries} as unknown as PerformanceObserverEntryList,
{} as PerformanceObserver,
)

const metrics = scopedCollector.getMetrics()
expect(metrics.elementCount).toBe(ATTRIBUTION_ENTRY_LIMIT + 5)
expect(metrics.elements).toHaveLength(ATTRIBUTION_ENTRY_LIMIT)
expect(metrics.elements[0]?.identifier).toBe('element-5')
scopedCollector.stop()
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'

import {ATTRIBUTION_SOURCE_LIMIT} from '../attribution'
import {LayoutShiftCollector} from '../layout-shift-collector'

const rect = {x: 1, y: 2, width: 3, height: 4} as DOMRectReadOnly
const movedRect = {x: 5, y: 2, width: 3, height: 4} as DOMRectReadOnly

describe('LayoutShiftCollector', () => {
let collector: LayoutShiftCollector
let observerCallback: PerformanceObserverCallback | null = null
Expand All @@ -26,6 +30,7 @@ describe('LayoutShiftCollector', () => {

afterEach(() => {
collector.stop()
vi.restoreAllMocks()
vi.unstubAllGlobals()
observerCallback = null
})
Expand Down Expand Up @@ -64,4 +69,58 @@ describe('LayoutShiftCollector', () => {
layoutShiftScore: 0,
})
})

it('captures bounded source selectors and geometry', () => {
vi.spyOn(performance, 'now').mockReturnValue(1_000)
collector.start()
const element = document.createElement('div')
element.id = 'shifted-card'

observerCallback?.(
{
getEntries: () => [
{
startTime: performance.now(),
value: 0.05,
hadRecentInput: false,
sources: [{node: element, previousRect: rect, currentRect: movedRect}],
},
],
} as unknown as PerformanceObserverEntryList,
{} as PerformanceObserver,
)

expect(collector.getMetrics().layoutShiftAttribution).toEqual([
{
startTime: 0,
score: 0.05,
sources: [
{
selector: '#shifted-card',
previousRect: {x: 1, y: 2, width: 3, height: 4},
currentRect: {x: 5, y: 2, width: 3, height: 4},
},
],
},
])
})

it('limits the number of sources retained for one shift', () => {
vi.spyOn(performance, 'now').mockReturnValue(1_000)
collector.start()
const sources = Array.from({length: ATTRIBUTION_SOURCE_LIMIT + 2}, (_, index) => {
const element = document.createElement('div')
element.id = `source-${String(index)}`
return {node: element, previousRect: rect, currentRect: rect}
})

observerCallback?.(
{
getEntries: () => [{startTime: 1_000, value: 0.05, hadRecentInput: false, sources}],
} as unknown as PerformanceObserverEntryList,
{} as PerformanceObserver,
)

expect(collector.getMetrics().layoutShiftAttribution[0]?.sources).toHaveLength(ATTRIBUTION_SOURCE_LIMIT)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,44 @@ describe('LongAnimationFrameCollector', () => {
expect(collector.getMetrics()).toMatchObject({loafCount: 1, longestLoafDuration: 80})
})

it('captures forced style and layout attribution', () => {
collector.start()
const startTime = performance.now()

observerCallback?.(
{
getEntries: () => [
{
startTime,
duration: 80,
blockingDuration: 30,
renderStart: startTime + 20,
styleAndLayoutStart: startTime + 30,
scripts: [
{
sourceURL: '/story.js',
sourceFunctionName: 'renderStory',
sourceCharPosition: 42,
invokerType: 'user-callback',
invoker: 'requestAnimationFrame',
executionStart: startTime + 5,
duration: 40,
forcedStyleAndLayoutDuration: 12,
},
{duration: 10, forcedStyleAndLayoutDuration: 3},
],
},
],
} as unknown as PerformanceObserverEntryList,
{} as PerformanceObserver,
)

expect(collector.getMetrics().lastLoaf).toMatchObject({
forcedStyleAndLayoutDuration: 15,
topScript: {sourceURL: '/story.js', forcedStyleAndLayoutDuration: 12},
})
})

describe('start/stop', () => {
it('can be started and stopped without error', () => {
expect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'

import {ATTRIBUTION_ENTRY_LIMIT} from '../attribution'
import {PaintCollector} from '../paint-collector'

/** Poll until a condition is met (MutationObserver + idle callback) */
Expand Down Expand Up @@ -82,6 +83,8 @@ describe('PaintCollector', () => {
const metrics = collector.getMetrics()
expect(metrics.paintCount).toBe(0)
expect(metrics.scriptEvalTime).toBe(0)
expect(metrics.scriptResourceCount).toBe(0)
expect(metrics.scriptResources).toEqual([])
expect(metrics.compositorLayers).toBeNull()
})

Expand Down Expand Up @@ -137,6 +140,7 @@ describe('PaintCollector', () => {
getEntries: () => [
{
entryType: 'resource',
name: '/assets/story.js',
startTime,
initiatorType: 'script',
fetchStart: 100,
Expand All @@ -149,6 +153,10 @@ describe('PaintCollector', () => {

const metrics = collector.getMetrics()
expect(metrics.scriptEvalTime).toBe(50)
expect(metrics.scriptResourceCount).toBe(1)
expect(metrics.scriptResources).toEqual([
expect.objectContaining({url: '/assets/story.js', initiatorType: 'script', duration: 50}),
])
})

it('accumulates script time from multiple scripts', () => {
Expand All @@ -169,6 +177,30 @@ describe('PaintCollector', () => {
expect(metrics.scriptEvalTime).toBe(80) // 30 + 50
})

it('retains only the slowest bounded script resources', () => {
collector.start()
const startTime = performance.now()
const entries = Array.from({length: ATTRIBUTION_ENTRY_LIMIT + 5}, (_, index) => ({
entryType: 'resource',
name: `/script-${String(index)}.js`,
initiatorType: 'script',
startTime,
fetchStart: 100,
responseEnd: 101 + index,
}))

resourceObserverCallback?.(
{getEntries: () => entries} as unknown as PerformanceObserverEntryList,
{} as PerformanceObserver,
)

const metrics = collector.getMetrics()
expect(metrics.scriptResourceCount).toBe(ATTRIBUTION_ENTRY_LIMIT + 5)
expect(metrics.scriptResources).toHaveLength(ATTRIBUTION_ENTRY_LIMIT)
expect(metrics.scriptResources[0]?.url).toBe('/script-24.js')
expect(metrics.scriptResources.at(-1)?.url).toBe('/script-5.js')
})

it('ignores non-script resources', () => {
collector.start()
const startTime = performance.now()
Expand Down
Loading