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} />