diff --git a/.changeset/add-native-attribution.md b/.changeset/add-native-attribution.md
new file mode 100644
index 0000000..c765b67
--- /dev/null
+++ b/.changeset/add-native-attribution.md
@@ -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.
\ 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 8d17220..c750465 100644
--- a/packages/storybook-addon-performance-panel/README.md
+++ b/packages/storybook-addon-performance-panel/README.md
@@ -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 (`
`), SVG ``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
@@ -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
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 64b59a1..05f1bcf 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
@@ -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')
@@ -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')
})
})
diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/attribution.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/attribution.browser.test.ts
new file mode 100644
index 0000000..ee884e2
--- /dev/null
+++ b/packages/storybook-addon-performance-panel/collectors/__tests__/attribution.browser.test.ts
@@ -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')
+ })
+})
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 b59c118..12dcee5 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
@@ -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')
@@ -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', () => {
diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/element-timing-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/element-timing-collector.browser.test.ts
index a23534e..16f5719 100644
--- a/packages/storybook-addon-performance-panel/collectors/__tests__/element-timing-collector.browser.test.ts
+++ b/packages/storybook-addon-performance-panel/collectors/__tests__/element-timing-collector.browser.test.ts
@@ -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
@@ -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: () => [],
@@ -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()
+ })
})
diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/layout-shift-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/layout-shift-collector.browser.test.ts
index 7f08bec..9784c59 100644
--- a/packages/storybook-addon-performance-panel/collectors/__tests__/layout-shift-collector.browser.test.ts
+++ b/packages/storybook-addon-performance-panel/collectors/__tests__/layout-shift-collector.browser.test.ts
@@ -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
@@ -26,6 +30,7 @@ describe('LayoutShiftCollector', () => {
afterEach(() => {
collector.stop()
+ vi.restoreAllMocks()
vi.unstubAllGlobals()
observerCallback = null
})
@@ -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)
+ })
})
diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/long-animation-frame-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/long-animation-frame-collector.browser.test.ts
index 494a849..72b19e2 100644
--- a/packages/storybook-addon-performance-panel/collectors/__tests__/long-animation-frame-collector.browser.test.ts
+++ b/packages/storybook-addon-performance-panel/collectors/__tests__/long-animation-frame-collector.browser.test.ts
@@ -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(() => {
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 7f54dbb..d6d70fa 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
@@ -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) */
@@ -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()
})
@@ -137,6 +140,7 @@ describe('PaintCollector', () => {
getEntries: () => [
{
entryType: 'resource',
+ name: '/assets/story.js',
startTime,
initiatorType: 'script',
fetchStart: 100,
@@ -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', () => {
@@ -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()
diff --git a/packages/storybook-addon-performance-panel/collectors/attribution.ts b/packages/storybook-addon-performance-panel/collectors/attribution.ts
new file mode 100644
index 0000000..7cba854
--- /dev/null
+++ b/packages/storybook-addon-performance-panel/collectors/attribution.ts
@@ -0,0 +1,36 @@
+export const ATTRIBUTION_ENTRY_LIMIT = 20
+export const ATTRIBUTION_SOURCE_LIMIT = 5
+const ATTRIBUTION_SELECTOR_MAX_LENGTH = 256
+export const ATTRIBUTION_URL_MAX_LENGTH = 512
+export const ATTRIBUTION_LABEL_MAX_LENGTH = 128
+
+export function limitAttributionString(value: string, fallback: string, maxLength: number): string {
+ const normalized = value || fallback
+ return normalized.length <= maxLength ? normalized : normalized.slice(0, maxLength)
+}
+
+export function getElementSelector(node: Node | null): string {
+ const element = node instanceof Element ? node : node?.parentElement
+ if (!element) return 'unknown'
+
+ if (element.id) {
+ return limitAttributionString(`#${element.id}`, 'unknown', ATTRIBUTION_SELECTOR_MAX_LENGTH)
+ }
+
+ const timing = element.getAttribute('elementtiming')
+ if (timing) {
+ return limitAttributionString(`[elementtiming="${timing}"]`, 'unknown', ATTRIBUTION_SELECTOR_MAX_LENGTH)
+ }
+
+ const className = typeof element.className === 'string' ? element.className : ''
+ const classes = className.split(/\s+/).filter(Boolean).slice(0, 2).join('.')
+ const selector = `${element.tagName.toLowerCase()}${classes ? `.${classes}` : ''}`
+ return limitAttributionString(selector, 'unknown', ATTRIBUTION_SELECTOR_MAX_LENGTH)
+}
+
+export function addBoundedAttribution(items: T[], item: T): void {
+ items.push(item)
+ if (items.length > ATTRIBUTION_ENTRY_LIMIT) {
+ items.splice(0, items.length - ATTRIBUTION_ENTRY_LIMIT)
+ }
+}
diff --git a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts
index b7b9a08..d22c249 100644
--- a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts
+++ b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts
@@ -350,6 +350,7 @@ export class CollectorManager {
layoutShiftScore: layoutShift.layoutShiftScore,
layoutShiftCount: layoutShift.layoutShiftCount,
currentSessionCLS: layoutShift.currentSessionScore,
+ layoutShiftAttribution: layoutShift.layoutShiftAttribution,
eventTimingSupported: input.eventTimingSupported,
interactionCount: input.interactionCount,
inpMs: input.inpMs,
@@ -370,6 +371,8 @@ export class CollectorManager {
observerCount: 0, // Not currently tracked by collectors
cssVarChanges: style.cssVarChanges,
scriptResourceLoadTime,
+ scriptResourceCount: paint.scriptResourceCount,
+ scriptResources: paint.scriptResources,
scriptEvalTime: scriptResourceLoadTime,
gcPressure: Math.round(memory.gcPressure * 100) / 100,
paintCount: paint.paintCount,
@@ -387,7 +390,12 @@ export class CollectorManager {
elementTimings: elementTiming.elements.map(e => ({
identifier: e.identifier,
renderTime: Math.round(e.renderTime * 10) / 10,
+ rawRenderTime: Math.round(e.rawRenderTime * 10) / 10,
+ loadTime: Math.round(e.loadTime * 10) / 10,
+ rawLoadTime: Math.round(e.rawLoadTime * 10) / 10,
selector: e.selector,
+ tagName: e.tagName,
+ ...(e.url ? {url: e.url} : {}),
})),
}
diff --git a/packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts b/packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts
index 2b167eb..3248987 100644
--- a/packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts
+++ b/packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts
@@ -11,6 +11,13 @@
* @see https://web.dev/articles/custom-metrics#element-timing-api
*/
+import {
+ addBoundedAttribution,
+ ATTRIBUTION_LABEL_MAX_LENGTH,
+ ATTRIBUTION_URL_MAX_LENGTH,
+ getElementSelector,
+ limitAttributionString,
+} from './attribution'
import type {MetricCollector} from './types'
/**
@@ -41,10 +48,14 @@ interface PerformanceElementTiming extends PerformanceEntry {
export interface ElementTimingRecord {
/** The elementtiming attribute value */
identifier: string
- /** Render time in milliseconds */
+ /** Effective render time relative to the story epoch; falls back to loadTime */
renderTime: number
+ /** Unmodified renderTime timestamp from the Performance Timeline */
+ rawRenderTime: number
/** Load time in milliseconds (for images, 0 otherwise) */
loadTime: number
+ /** Unmodified loadTime timestamp from the Performance Timeline */
+ rawLoadTime: number
/** CSS selector for the element */
selector: string
/** Element tag name */
@@ -67,35 +78,6 @@ export interface ElementTimingMetrics {
elementCount: number
}
-/**
- * Generates a simple CSS selector for an element
- */
-function getSimpleSelector(element: Element | null): string {
- if (!element) return 'unknown'
-
- // Try ID first
- if (element.id) {
- return `#${element.id}`
- }
-
- // Try elementtiming attribute
- const timing = element.getAttribute('elementtiming')
- if (timing) {
- return `[elementtiming="${timing}"]`
- }
-
- // Fall back to tag + class
- const classes = element.className
- ? `.${element.className
- .split(/\s+/)
- .filter(c => c)
- .slice(0, 2)
- .join('.')}`
- : ''
-
- return `${element.tagName.toLowerCase()}${classes}`
-}
-
/**
* Collects Element Timing metrics for elements with the `elementtiming` attribute.
*
@@ -108,6 +90,7 @@ export class ElementTimingCollector implements MetricCollector 0 ? Math.max(0, entry.loadTime - this.#epochMs) : 0,
- selector: getSimpleSelector(entry.element),
+ rawLoadTime: entry.loadTime,
+ selector: getElementSelector(entry.element),
tagName: entry.element?.tagName.toLowerCase() ?? 'unknown',
}
@@ -165,10 +150,11 @@ export class ElementTimingCollector implements MetricCollector this.#largestRenderTime) {
@@ -183,6 +169,7 @@ export class ElementTimingCollector implements MetricCollector ({...element})),
largestRenderTime: this.#largestRenderTime,
- elementCount: this.#elements.length,
+ elementCount: this.#elementCount,
}
}
}
diff --git a/packages/storybook-addon-performance-panel/collectors/layout-shift-collector.ts b/packages/storybook-addon-performance-panel/collectors/layout-shift-collector.ts
index 777aa65..390e5e5 100644
--- a/packages/storybook-addon-performance-panel/collectors/layout-shift-collector.ts
+++ b/packages/storybook-addon-performance-panel/collectors/layout-shift-collector.ts
@@ -10,6 +10,8 @@
* @see https://web.dev/articles/evolving-cls
*/
+import type {AttributionRect, LayoutShiftAttribution} from '../core/performance-types'
+import {addBoundedAttribution, ATTRIBUTION_SOURCE_LIMIT, getElementSelector} from './attribution'
import type {MetricCollector} from './types'
export interface LayoutMetrics {
@@ -21,10 +23,12 @@ export interface LayoutMetrics {
currentSessionScore: number
/** Number of completed sessions */
sessionCount: number
+ /** Recent layout shifts with bounded source attribution */
+ layoutShiftAttribution: LayoutShiftAttribution[]
}
// These are in a later version of TypeScript's DOM lib, so we redefine them here
-interface LayoutShiftAttribution {
+interface NativeLayoutShiftSource {
/** The DOM node that shifted. May be null if not exposed. */
node: Node | null
@@ -43,7 +47,7 @@ interface LayoutShift extends PerformanceEntry {
value: number
/** Sources contributing to this layout shift. */
- sources: readonly LayoutShiftAttribution[]
+ sources?: readonly NativeLayoutShiftSource[]
}
/** Maximum gap between shifts in a session (1 second) */
@@ -74,6 +78,7 @@ export class LayoutShiftCollector implements MetricCollector {
#layoutShiftCount = 0
/** Number of completed sessions */
#sessionCount = 0
+ #layoutShiftAttribution: LayoutShiftAttribution[] = []
/** Entries before this timestamp belong to an earlier story or reset. */
#epochMs = 0
@@ -101,6 +106,19 @@ export class LayoutShiftCollector implements MetricCollector {
this.#layoutShiftCount++
+ const sources = (entry.sources ?? []).slice(0, ATTRIBUTION_SOURCE_LIMIT).map(source => ({
+ selector: getElementSelector(source.node),
+ previousRect: this.#toAttributionRect(source.previousRect),
+ currentRect: this.#toAttributionRect(source.currentRect),
+ }))
+ if (sources.length > 0) {
+ addBoundedAttribution(this.#layoutShiftAttribution, {
+ startTime: Math.max(0, entry.startTime - this.#epochMs),
+ score: Math.round(entry.value * 10000) / 10000,
+ sources,
+ })
+ }
+
// Check if this entry belongs to the current session or starts a new one
const shouldStartNewSession =
this.#sessionFirstEntryTime === null ||
@@ -149,6 +167,7 @@ export class LayoutShiftCollector implements MetricCollector {
this.#sessionLastEntryTime = null
this.#layoutShiftCount = 0
this.#sessionCount = 0
+ this.#layoutShiftAttribution = []
this.#epochMs = performance.now()
}
@@ -158,6 +177,19 @@ export class LayoutShiftCollector implements MetricCollector {
layoutShiftCount: this.#layoutShiftCount,
currentSessionScore: Math.round(this.#currentSessionScore * 10000) / 10000,
sessionCount: this.#sessionCount,
+ layoutShiftAttribution: this.#layoutShiftAttribution.map(attribution => ({
+ ...attribution,
+ sources: attribution.sources.map(source => ({...source})),
+ })),
+ }
+ }
+
+ #toAttributionRect(rect: DOMRectReadOnly): AttributionRect {
+ return {
+ x: Math.round(rect.x * 10) / 10,
+ y: Math.round(rect.y * 10) / 10,
+ width: Math.round(rect.width * 10) / 10,
+ height: Math.round(rect.height * 10) / 10,
}
}
}
diff --git a/packages/storybook-addon-performance-panel/collectors/long-animation-frame-collector.ts b/packages/storybook-addon-performance-panel/collectors/long-animation-frame-collector.ts
index c38c409..e768686 100644
--- a/packages/storybook-addon-performance-panel/collectors/long-animation-frame-collector.ts
+++ b/packages/storybook-addon-performance-panel/collectors/long-animation-frame-collector.ts
@@ -8,7 +8,8 @@
* @see https://w3c.github.io/long-animation-frames/
*/
-import type {LoAFScriptAttribution} from '../core/performance-types'
+import type {LoAFDetails, LoAFScriptAttribution} from '../core/performance-types'
+import {ATTRIBUTION_LABEL_MAX_LENGTH, ATTRIBUTION_URL_MAX_LENGTH, limitAttributionString} from './attribution'
import type {MetricCollector} from './types'
import {addToWindow, computeAverage, computeP95} from './utils'
@@ -51,7 +52,7 @@ interface PerformanceScriptTiming extends PerformanceEntry {
/** Duration of script execution */
duration: number
/** Time forced style/layout took */
- forcedStyleAndLayoutDuration: DOMHighResTimeStamp
+ forcedStyleAndLayoutDuration?: DOMHighResTimeStamp
/** Window attribution */
window: Window | null
/** Window attribution string */
@@ -79,23 +80,9 @@ export interface LongAnimationFrameMetrics {
/** Count of LoAFs with script attribution */
loafsWithScripts: number
/** Most recent LoAF details for debugging */
- lastLoaf: {
- duration: number
- blockingDuration: number
- renderStart: number
- styleAndLayoutStart: number
- scriptCount: number
- topScript: LoAFScriptAttribution | null
- } | null
+ lastLoaf: LoAFDetails | null
/** Details about the worst (longest) LoAF */
- worstLoaf: {
- duration: number
- blockingDuration: number
- renderStart: number
- styleAndLayoutStart: number
- scriptCount: number
- topScript: LoAFScriptAttribution | null
- } | null
+ worstLoaf: LoAFDetails | null
}
/**
@@ -177,17 +164,23 @@ export class LongAnimationFrameCollector implements MetricCollector total + (script.forcedStyleAndLayoutDuration ?? 0),
+ 0,
+ )
+
// Build frame details
const frameDetails = {
duration: entry.duration,
@@ -195,6 +188,7 @@ export class LongAnimationFrameCollector implements MetricCollector {
#paintCount = 0
#scriptEvalTime = 0
+ #scriptResourceCount = 0
+ #scriptResources: ScriptResourceAttribution[] = []
#compositorLayers: number | null = null
/** Elements currently known to have compositor-layer-promoting properties */
@@ -84,6 +95,19 @@ export class PaintCollector implements MetricCollector {
const scriptTime = resourceEntry.responseEnd - resourceEntry.fetchStart
if (scriptTime > 0) {
this.#scriptEvalTime += scriptTime
+ this.#scriptResourceCount++
+ this.#scriptResources.push({
+ url: limitAttributionString(resourceEntry.name, 'unknown', ATTRIBUTION_URL_MAX_LENGTH),
+ initiatorType: limitAttributionString(
+ resourceEntry.initiatorType,
+ 'unknown',
+ ATTRIBUTION_LABEL_MAX_LENGTH,
+ ),
+ startTime: Math.max(0, resourceEntry.startTime - this.#epochMs),
+ duration: scriptTime,
+ })
+ this.#scriptResources.sort((a, b) => b.duration - a.duration)
+ this.#scriptResources.length = Math.min(this.#scriptResources.length, ATTRIBUTION_ENTRY_LIMIT)
}
}
}
@@ -109,6 +133,8 @@ export class PaintCollector implements MetricCollector {
reset(): void {
this.#paintCount = 0
this.#scriptEvalTime = 0
+ this.#scriptResourceCount = 0
+ this.#scriptResources = []
this.#compositorLayers = null
this.#layerElements.clear()
this.#pendingChecks.clear()
@@ -260,6 +286,8 @@ export class PaintCollector implements MetricCollector {
return {
paintCount: this.#paintCount,
scriptEvalTime: this.#scriptEvalTime,
+ scriptResourceCount: this.#scriptResourceCount,
+ scriptResources: this.#scriptResources.map(resource => ({...resource})),
compositorLayers: this.#compositorLayers,
}
}
diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts
index ba10aae..3487fd9 100644
--- a/packages/storybook-addon-performance-panel/core/performance-types.ts
+++ b/packages/storybook-addon-performance-panel/core/performance-types.ts
@@ -317,6 +317,8 @@ export interface LoAFScriptAttribution {
executionStart: number
/** Duration this script executed (ms) */
duration: number
+ /** Time this script forced style and layout work (ms) */
+ forcedStyleAndLayoutDuration: number
}
/**
@@ -334,10 +336,53 @@ export interface LoAFDetails {
styleAndLayoutStart: number
/** Number of scripts that contributed */
scriptCount: number
+ /** Total forced style and layout duration attributed to scripts (ms) */
+ forcedStyleAndLayoutDuration: number
/** The script that contributed the most time */
topScript: LoAFScriptAttribution | null
}
+export interface AttributionRect {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export interface LayoutShiftSourceAttribution {
+ selector: string
+ previousRect: AttributionRect
+ currentRect: AttributionRect
+}
+
+export interface LayoutShiftAttribution {
+ startTime: number
+ score: number
+ sources: LayoutShiftSourceAttribution[]
+}
+
+export interface ScriptResourceAttribution {
+ url: string
+ initiatorType: string
+ startTime: number
+ duration: number
+}
+
+export interface ElementTimingAttribution {
+ identifier: string
+ /** Effective render time relative to the story epoch; falls back to loadTime (ms) */
+ renderTime: number
+ /** Unmodified renderTime timestamp from the Performance Timeline (ms) */
+ rawRenderTime: number
+ /** Load time relative to the current story epoch (ms) */
+ loadTime: number
+ /** Unmodified loadTime timestamp from the Performance Timeline (ms) */
+ rawLoadTime: number
+ selector: string
+ tagName: string
+ url?: string
+}
+
/**
* Performance metrics transmitted from decorator to panel.
*
@@ -486,6 +531,8 @@ export interface PerformanceMetrics {
layoutShiftCount: number
/** Current/ongoing session's CLS value */
currentSessionCLS: number
+ /** Recent layout shifts with bounded source selectors and geometry */
+ layoutShiftAttribution: LayoutShiftAttribution[]
/** Synchronous reads that forced browser reflow */
forcedReflowCount: number
/** Average DOM mutations normalized to a one-second rate */
@@ -522,6 +569,10 @@ export interface PerformanceMetrics {
domElements: number | null
/** Cumulative script resource loading time from the Resource Timing API (ms) */
scriptResourceLoadTime: number
+ /** Total script resources observed in the current story */
+ scriptResourceCount: number
+ /** Bounded attribution for the slowest observed script resources */
+ scriptResources: ScriptResourceAttribution[]
/** @deprecated Use scriptResourceLoadTime. */
scriptEvalTime: number
@@ -546,8 +597,8 @@ export interface PerformanceMetrics {
elementTimingCount: number
/** Largest render time across all tracked elements (ms) */
largestElementRenderTime: number
- /** Details about tracked elements (identifier → renderTime) */
- elementTimings: {identifier: string; renderTime: number; selector: string}[]
+ /** Bounded details about tracked elements with relative and raw timestamps */
+ elementTimings: ElementTimingAttribution[]
}
/** How a metric is obtained from its underlying browser or framework signal. */
@@ -638,6 +689,7 @@ export const PERFORMANCE_METRIC_METADATA = {
layoutShiftScore: {provenance: 'derived', quality: 'high', unit: 'score'},
layoutShiftCount: {provenance: 'native', quality: 'high', unit: 'count'},
currentSessionCLS: {provenance: 'derived', quality: 'high', unit: 'score'},
+ layoutShiftAttribution: {provenance: 'native', quality: 'high', unit: 'structured'},
forcedReflowCount: {provenance: 'heuristic', quality: 'low', unit: 'count'},
domMutationsPerSecond: {provenance: 'derived', quality: 'medium', unit: 'per-second'},
domMutationsPerFrame: {provenance: 'derived', quality: 'medium', unit: 'count'},
@@ -652,6 +704,8 @@ export const PERFORMANCE_METRIC_METADATA = {
renderCascades: {provenance: 'derived', quality: 'high', unit: 'count'},
domElements: {provenance: 'derived', quality: 'high', unit: 'count'},
scriptResourceLoadTime: {provenance: 'derived', quality: 'high', unit: 'milliseconds'},
+ scriptResourceCount: {provenance: 'native', quality: 'high', unit: 'count'},
+ scriptResources: {provenance: 'native', quality: 'high', unit: 'structured'},
scriptEvalTime: {provenance: 'derived', quality: 'high', unit: 'milliseconds'},
eventListenerCount: {provenance: 'unsupported', quality: 'unavailable', unit: 'count'},
observerCount: {provenance: 'unsupported', quality: 'unavailable', unit: 'count'},
@@ -725,6 +779,7 @@ export const DEFAULT_METRICS: PerformanceMetrics = {
layoutShiftScore: 0,
layoutShiftCount: 0,
currentSessionCLS: 0,
+ layoutShiftAttribution: [],
forcedReflowCount: 0,
domMutationsPerSecond: 0,
domMutationsPerFrame: 0,
@@ -739,6 +794,8 @@ export const DEFAULT_METRICS: PerformanceMetrics = {
renderCascades: 0,
domElements: null,
scriptResourceLoadTime: 0,
+ scriptResourceCount: 0,
+ scriptResources: [],
scriptEvalTime: 0,
eventListenerCount: 0,
observerCount: 0,
diff --git a/packages/storybook-addon-performance-panel/index-universal.ts b/packages/storybook-addon-performance-panel/index-universal.ts
index f64c0e8..cdfdfd8 100644
--- a/packages/storybook-addon-performance-panel/index-universal.ts
+++ b/packages/storybook-addon-performance-panel/index-universal.ts
@@ -22,12 +22,17 @@ const start = () => definePreviewAddon(addonAnnotations)
export default start
export type {
+ AttributionRect,
+ ElementTimingAttribution,
+ LayoutShiftAttribution,
+ LayoutShiftSourceAttribution,
MetricProvenance,
MetricQuality,
MetricUnit,
PerformanceMetricMetadata,
PerformanceMetrics,
PerformancePanelParameters,
+ ScriptResourceAttribution,
} 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 b54ce72..d99ba7d 100644
--- a/packages/storybook-addon-performance-panel/index.ts
+++ b/packages/storybook-addon-performance-panel/index.ts
@@ -7,12 +7,17 @@ export default start
// Public API for manual per-story usage (framework-agnostic)
export type {
+ AttributionRect,
+ ElementTimingAttribution,
+ LayoutShiftAttribution,
+ LayoutShiftSourceAttribution,
MetricProvenance,
MetricQuality,
MetricUnit,
PerformanceMetricMetadata,
PerformanceMetrics,
PerformancePanelParameters,
+ ScriptResourceAttribution,
} 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 91faa8c..de0c525 100644
--- a/packages/storybook-addon-performance-panel/performance-panel.tsx
+++ b/packages/storybook-addon-performance-panel/performance-panel.tsx
@@ -901,6 +901,16 @@ const LoAFSection = React.memo(function LoAFSection({
{Math.round(worstLoaf.topScript.duration)}ms
)}
+
+ {worstLoaf && worstLoaf.forcedStyleAndLayoutDuration > 0 && (
+ top script: {worstLoaf.topScript.forcedStyleAndLayoutDuration}ms> : null}
+ >
+ {Math.round(worstLoaf.forcedStyleAndLayoutDuration)}ms
+
+ )}
)
})
@@ -989,7 +999,7 @@ const ElementTimingSection = React.memo(function ElementTimingSection({
{i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : '🥉 '}
@@ -1021,24 +1031,31 @@ type LayoutAndInternalsSectionProps = Pick<
| 'layoutShiftScore'
| 'layoutShiftCount'
| 'currentSessionCLS'
+ | 'layoutShiftAttribution'
| 'forcedReflowCount'
| 'styleWrites'
| 'cssVarChanges'
| 'inputJitter'
->
+> & {
+ onInspectElement?: (selector: string) => void
+}
const LayoutAndInternalsSection = React.memo(function LayoutAndInternalsSection({
layoutShiftScore,
layoutShiftCount,
currentSessionCLS,
+ layoutShiftAttribution,
forcedReflowCount,
styleWrites,
cssVarChanges,
inputJitter,
+ onInspectElement,
}: LayoutAndInternalsSectionProps) {
const clsStatus = getStatus(layoutShiftScore, THRESHOLDS.CLS_GOOD, THRESHOLDS.CLS_WARNING)
const reflowStatus = getStatus(forcedReflowCount, 0, THRESHOLDS.FORCED_REFLOW_WARNING)
const jitterStatus = getZeroStatus(inputJitter)
+ const latestShift = layoutShiftAttribution.at(-1)
+ const latestShiftSource = latestShift?.sources[0]
// Build detail parts - always show both when available
const detailParts: string[] = []
@@ -1066,6 +1083,26 @@ const LayoutAndInternalsSection = React.memo(function LayoutAndInternalsSection(
+ {latestShiftSource && (
+ score: {formatScore(latestShift.score)}>}
+ >
+ {latestShiftSource.selector.slice(0, 24)}
+ {latestShiftSource.selector !== 'unknown' && onInspectElement && (
+ {
+ onInspectElement(latestShiftSource.selector)
+ }}
+ title="Inspect latest layout shift source"
+ >
+ 🔍
+
+ )}
+
+ )}
+
const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection({
@@ -1295,10 +1335,19 @@ const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection(
domElements,
initialPaintMilestones,
layerPromotionCandidates,
+ scriptResourceLoadTime,
+ scriptResourceCount,
+ scriptResources,
}: MemoryAndRenderingSectionProps) {
const gcStatus = getStatus(gcPressure, 0, THRESHOLDS.GC_PRESSURE_WARNING)
const layerStatus =
layerPromotionCandidates === null ? 'neutral' : getStatus(layerPromotionCandidates, 0, THRESHOLDS.LAYERS_WARNING)
+ const slowestScriptResource = scriptResources[0]
+ const scriptResourceDetail = slowestScriptResource ? (
+ <>
+ {slowestScriptResource.initiatorType} · {slowestScriptResource.url.slice(0, 24)}
+ >
+ ) : null
const deltaStatus =
memoryDeltaMB === null
@@ -1330,6 +1379,14 @@ const MemoryAndRenderingSection = React.memo(function MemoryAndRenderingSection(
>
{initialPaintMilestones}
+
+ {formatMs(scriptResourceLoadTime)}
+ / {scriptResourceCount} scripts
+
+
+
+ {formatMs(scriptResourceLoadTime)}
+ / {scriptResourceCount} scripts
+
)
})
@@ -1697,10 +1763,12 @@ function ConnectedPanelContent({storyId}: {storyId: string}) {
layoutShiftScore={metrics.layoutShiftScore}
layoutShiftCount={metrics.layoutShiftCount}
currentSessionCLS={metrics.currentSessionCLS}
+ layoutShiftAttribution={metrics.layoutShiftAttribution}
forcedReflowCount={metrics.forcedReflowCount}
styleWrites={metrics.styleWrites}
cssVarChanges={metrics.cssVarChanges}
inputJitter={metrics.inputJitter}
+ onInspectElement={handleInspectElement}
/>