From 01dba736dddb57e029d08d8cf8aea2f695b233fd Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 15:39:00 +0000 Subject: [PATCH] Reduce collector overhead --- .changeset/reduce-collector-overhead.md | 5 + .../README.md | 22 +- .../__benchmarks__/compare-results.ts | 81 ++++++ .../__benchmarks__/overhead.browser.bench.tsx | 62 ++++- ...rmance-decorator-universal.browser.test.ts | 29 ++- .../collectors/README.md | 46 +--- .../__tests__/attribution.browser.test.ts | 32 ++- .../collector-manager.browser.test.ts | 62 +++-- .../forced-reflow-collector.browser.test.ts | 115 --------- .../__tests__/input-collector.browser.test.ts | 71 ++++- ...-animation-frame-collector.browser.test.ts | 2 + .../__tests__/paint-collector.browser.test.ts | 129 +++++++-- .../style-mutation-collector.browser.test.ts | 69 ++--- .../collectors/attribution.ts | 14 +- .../collectors/collector-manager.ts | 71 +++-- .../collectors/element-timing-collector.ts | 16 +- .../collectors/forced-reflow-collector.ts | 211 --------------- .../collectors/frame-timing-collector.ts | 75 ++++-- .../collectors/input-collector.ts | 105 ++++++-- .../collectors/layout-shift-collector.ts | 17 +- .../long-animation-frame-collector.ts | 24 +- .../collectors/main-thread-collector.ts | 25 +- .../collectors/paint-collector.ts | 244 ++++++++++++------ .../collectors/react-profiler-collector.ts | 16 ++ .../collectors/style-mutation-collector.ts | 92 ++++--- .../overhead-telemetry.browser.test.ts | 36 +++ .../core/overhead-telemetry.ts | 96 +++++++ .../core/performance-types.ts | 8 +- .../core/preview-core.ts | 36 ++- .../package.json | 1 + .../performance-panel.tsx | 29 ++- 31 files changed, 1183 insertions(+), 658 deletions(-) create mode 100644 .changeset/reduce-collector-overhead.md create mode 100644 packages/storybook-addon-performance-panel/__benchmarks__/compare-results.ts delete mode 100644 packages/storybook-addon-performance-panel/collectors/__tests__/forced-reflow-collector.browser.test.ts delete mode 100644 packages/storybook-addon-performance-panel/collectors/forced-reflow-collector.ts create mode 100644 packages/storybook-addon-performance-panel/core/__tests__/overhead-telemetry.browser.test.ts create mode 100644 packages/storybook-addon-performance-panel/core/overhead-telemetry.ts diff --git a/.changeset/reduce-collector-overhead.md b/.changeset/reduce-collector-overhead.md new file mode 100644 index 0000000..d236dcd --- /dev/null +++ b/.changeset/reduce-collector-overhead.md @@ -0,0 +1,5 @@ +--- +'@github-ui/storybook-addon-performance-panel': minor +--- + +Reduce open-panel overhead by scoping DOM work to the story root, coalescing pointer RAFs, chunking layer scans, replacing global forced-reflow patches with native LoAF evidence, and adding benchmark-only overhead telemetry. \ 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 c750465..a16902e 100644 --- a/packages/storybook-addon-performance-panel/README.md +++ b/packages/storybook-addon-performance-panel/README.md @@ -112,7 +112,7 @@ The addon consists of two main parts: ### 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 +- **Forced Layout LoAFs**: Long animation frames with native forced style/layout attribution (Chrome/Edge) - **Style Writes**: Inline style mutations observed via MutationObserver ### React Performance @@ -203,6 +203,8 @@ Quality is `high` for direct or deterministic signals, `medium` for sampled or b Browser performance collection runs automatically while the Performance panel is selected. Closing the panel disconnects browser collectors, DOM observers, and live-update timers to minimize background overhead. Reopening the panel resumes collection without clearing the metrics already gathered for the current story. React Profiler callbacks remain attached so mount and render history is not lost before the panel opens. +DOM mutation observation and layer-promotion scans are scoped to the active story root. Layer checks are split into bounded idle-callback chunks, pointer samples share one cancellable RAF pipeline, and collection does not patch DOM or CSS prototypes. + Use the reset button to clear accumulated metrics, or set `parameters.performancePanel.disable` to `true` to disable the addon for a story. ## Collectors @@ -225,7 +227,6 @@ The addon uses modular collector classes for metrics gathering. Each collector u | `MemoryCollector` | `performance.memory` | **Optimal** | | `PaintCollector` | Paint + Resource Timing APIs, CSS heuristic | Mixed | | `StyleMutationCollector` | `MutationObserver` | Heuristic | -| `ForcedReflowCollector` | Property getter instrumentation | Heuristic | | `ReactProfilerCollector` | React Profiler API | **Optimal** | ## Browser Compatibility @@ -247,8 +248,16 @@ npm run tsc -w @github-ui/storybook-addon-performance-panel # Lint npm run lint -w @github-ui/storybook-addon-performance-panel + +# Write a candidate benchmark without replacing the tracked baseline +cd packages/storybook-addon-performance-panel +../../node_modules/.bin/vitest bench --config vitest.benchmark.config.ts --outputJson .overhead-current.json +npm run benchmark:compare -- .overhead-current.json +rm .overhead-current.json ``` +The benchmark also prints one internal `OVERHEAD_TELEMETRY_SNAPSHOT` after timed samples complete. It reports collector callback timing, `computeMetrics()` timing, serialization duration and bytes, scan counts, and current/peak pending work. This telemetry is opt-in benchmark instrumentation and is not part of `PerformanceMetrics` or live addon payloads. + ## Related Files - [performance-decorator.tsx](./performance-decorator.tsx) - Metrics collection in preview iframe @@ -423,15 +432,16 @@ Start by scanning these key indicators: --- -#### πŸ”₯ Forced Reflows (Layout Thrashing) +#### πŸ”₯ Forced Style & Layout (Layout Thrashing) **Symptoms:** -- `Forced Reflows` count >0 +- `Forced Layout LoAFs` count >0 +- `Forced Style / Layout` duration appears on the worst LoAF - `Thrashing` score increasing - FPS drops during interactions **Where to Look:** -1. Check `Forced Reflows` count +1. Check `Forced Layout LoAFs` and the worst LoAF attribution 2. Look for `Thrashing` correlation with long frames 3. Review `Style Writes` frequency @@ -524,7 +534,7 @@ Use these correlations to triangulate issues: | If you see... | Also check... | Likely cause | |---------------|---------------|--------------| | Low FPS + High Long Tasks | TBT, Longest Task | Heavy JS execution | -| Low FPS + High Style Writes | Thrashing, Forced Reflows | Layout thrashing | +| Low FPS + High Style Writes | Thrashing, Forced Layout LoAFs | Layout thrashing | | High INP + High Wait phase | Long Tasks | Blocked main thread | | High INP + High JS phase | Slow Updates, P95 | Expensive handlers | | High INP + High Paint phase | CLS, DOM Churn | Expensive rendering | diff --git a/packages/storybook-addon-performance-panel/__benchmarks__/compare-results.ts b/packages/storybook-addon-performance-panel/__benchmarks__/compare-results.ts new file mode 100644 index 0000000..762dc85 --- /dev/null +++ b/packages/storybook-addon-performance-panel/__benchmarks__/compare-results.ts @@ -0,0 +1,81 @@ +import {readFile} from 'node:fs/promises' +import {argv} from 'node:process' + +interface BenchmarkSummary { + group: string + name: string + mean: number + p99: number +} + +const [baselinePath, candidatePath] = argv.slice(2) + +if (!baselinePath || !candidatePath) { + throw new Error('Usage: compare-results.ts ') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function readBenchmarks(filePath: string): Promise> { + const parsed: unknown = JSON.parse(await readFile(filePath, 'utf8')) + if (!isRecord(parsed) || !Array.isArray(parsed.files)) { + throw new Error(`${filePath} is not a Vitest benchmark result`) + } + + const benchmarks = new Map() + for (const file of parsed.files) { + if (!isRecord(file) || !Array.isArray(file.groups)) continue + for (const group of file.groups) { + if (!isRecord(group) || typeof group.fullName !== 'string' || !Array.isArray(group.benchmarks)) continue + for (const benchmark of group.benchmarks) { + if ( + !isRecord(benchmark) || + typeof benchmark.name !== 'string' || + typeof benchmark.mean !== 'number' || + typeof benchmark.p99 !== 'number' + ) { + continue + } + benchmarks.set(`${group.fullName}::${benchmark.name}`, { + group: group.fullName.split(' > ').at(-1) ?? group.fullName, + name: benchmark.name, + mean: benchmark.mean, + p99: benchmark.p99, + }) + } + } + } + return benchmarks +} + +function formatDuration(value: number): string { + return `${value.toFixed(4)} ms` +} + +function formatDelta(baseline: number, candidate: number): string { + if (baseline === 0) return 'n/a' + const delta = ((candidate - baseline) / baseline) * 100 + return `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}%` +} + +const baseline = await readBenchmarks(baselinePath) +const candidate = await readBenchmarks(candidatePath) +const rows: {baseline: BenchmarkSummary; candidate: BenchmarkSummary}[] = [] + +for (const [key, baselineResult] of baseline) { + const candidateResult = candidate.get(key) + if (!candidateResult) { + throw new Error(`Candidate results are missing ${key}`) + } + rows.push({baseline: baselineResult, candidate: candidateResult}) +} + +console.log('| Workload | State | Baseline mean | Candidate mean | Mean delta | Baseline p99 | Candidate p99 |') +console.log('| --- | --- | ---: | ---: | ---: | ---: | ---: |') +for (const row of rows) { + console.log( + `| ${row.baseline.group} | ${row.baseline.name} | ${formatDuration(row.baseline.mean)} | ${formatDuration(row.candidate.mean)} | ${formatDelta(row.baseline.mean, row.candidate.mean)} | ${formatDuration(row.baseline.p99)} | ${formatDuration(row.candidate.p99)} |`, + ) +} diff --git a/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx b/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx index 47316e6..7c31987 100644 --- a/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx +++ b/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx @@ -1,8 +1,9 @@ import {flushSync} from 'react-dom' import {createRoot, type Root} from 'react-dom/client' import {addons} from 'storybook/preview-api' -import {bench, type BenchOptions, describe, vi} from 'vitest' +import {afterAll, bench, type BenchOptions, describe, vi} from 'vitest' +import {OverheadTelemetry} from '../core/overhead-telemetry' import {PERF_EVENTS} from '../core/performance-types' import {PerformanceMonitorCore} from '../core/preview-core' import {PerformanceProvider, ProfiledComponent} from '../react/performance-decorator' @@ -64,6 +65,61 @@ function setPanelVisibility(visible: boolean): void { addons.getChannel().emit(PERF_EVENTS.PANEL_VISIBILITY, visible) } +function nextAnimationFrame(): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => { + resolve() + }) + }) +} + +function nextIdlePeriod(): Promise { + return new Promise(resolve => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(() => { + resolve() + }) + } else { + setTimeout(resolve, 0) + } + }) +} + +async function runTelemetryProbe(): Promise { + const telemetry = new OverheadTelemetry() + const container = document.createElement('div') + document.body.appendChild(container) + const core = new PerformanceMonitorCore('benchmark-telemetry', {overheadTelemetry: telemetry}) + + try { + core.start() + core.observeContainer(container) + setPanelVisibility(true) + + const fragment = document.createDocumentFragment() + for (let index = 0; index < DOM_ROW_COUNT; index++) { + const row = document.createElement('div') + row.style.willChange = index % 4 === 0 ? 'transform' : 'auto' + row.textContent = `Telemetry row ${String(index)}` + fragment.appendChild(row) + } + container.replaceChildren(fragment) + window.dispatchEvent(new PointerEvent('pointermove')) + + await yieldToMainThread() + await nextAnimationFrame() + await nextAnimationFrame() + await nextIdlePeriod() + await nextIdlePeriod() + addons.getChannel().emit(PERF_EVENTS.REQUEST_METRICS) + } finally { + core.stop() + container.remove() + } + + console.info('OVERHEAD_TELEMETRY_SNAPSHOT', JSON.stringify(telemetry.snapshot())) +} + function createLifecycleBenchmark(state: LifecycleState): () => void { return () => { const container = document.createElement('div') @@ -217,3 +273,7 @@ describe('React commit workload', () => { bench(state, workload.run, workload.options) } }) + +afterAll(async () => { + await runTelemetryProbe() +}) diff --git a/packages/storybook-addon-performance-panel/__tests__/performance-decorator-universal.browser.test.ts b/packages/storybook-addon-performance-panel/__tests__/performance-decorator-universal.browser.test.ts index 71260e0..2154e1e 100644 --- a/packages/storybook-addon-performance-panel/__tests__/performance-decorator-universal.browser.test.ts +++ b/packages/storybook-addon-performance-panel/__tests__/performance-decorator-universal.browser.test.ts @@ -8,8 +8,9 @@ import type {StoryContext} from 'storybook/internal/types' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {OverheadTelemetry} from '../core/overhead-telemetry' import {PERF_EVENTS} from '../core/performance-types' -import {getActiveCore, setActiveCore} from '../core/preview-core' +import {getActiveCore, PerformanceMonitorCore, setActiveCore} from '../core/preview-core' // ── Mock storybook channel ────────────────────────────────────────────────── @@ -130,6 +131,32 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => { expect(mockChannel.emit).not.toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object)) }) + it('records channel payload serialization only with internal telemetry', () => { + const telemetry = new OverheadTelemetry() + const core = new PerformanceMonitorCore('telemetry-story', {overheadTelemetry: telemetry}) + core.start() + const visibilityCall = mockChannel.on.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY) + const handleVisibility = visibilityCall?.[1] as (visible: boolean) => void + + handleVisibility(true) + + const snapshot = telemetry.snapshot() + expect(snapshot.serialization.count).toBe(1) + expect(snapshot.serialization.bytes).toBeGreaterThan(0) + + core.manager.reportRender({ + profilerId: 'telemetry-profiler', + storyId: 'telemetry-story', + phase: 'mount', + actualDuration: 5, + baseDuration: 6, + startTime: 10, + commitTime: 15, + }) + expect(telemetry.snapshot().serialization.count).toBe(2) + core.stop() + }) + it('reuses the same core for repeated renders of the same story', () => { const ctx = makeCtx() diff --git a/packages/storybook-addon-performance-panel/collectors/README.md b/packages/storybook-addon-performance-panel/collectors/README.md index fd6261d..ed69204 100644 --- a/packages/storybook-addon-performance-panel/collectors/README.md +++ b/packages/storybook-addon-performance-panel/collectors/README.md @@ -15,7 +15,6 @@ This directory contains modular metric collector classes used by the performance | [MemoryCollector](#memorycollector) | `performance.memory` | **Optimal** | Excellent | Only available API (Chrome-only) | | [PaintCollector](#paintcollector) | Paint + Resource Timing APIs, CSS scan | Mixed | Good | Native milestones, derived loading time, heuristic candidates | | [StyleMutationCollector](#stylemutationcollector) | `MutationObserver` | Heuristic | Good | Only available method for DOM tracking | -| [ForcedReflowCollector](#forcedreflowcollector) | Property getter instrumentation | Heuristic | Moderate | Approximation via property access patterns | | [ReactProfilerCollector](#reactprofilercollector) | React Profiler API | **Optimal** | Excellent | Official React instrumentation | ### Legend @@ -195,10 +194,12 @@ this.#observer.observe({type: 'longtask'}) - `avgLoafDuration` - Average LoAF duration - `p95LoafDuration` - 95th percentile LoAF duration - `loafsWithScripts` - Count of LoAFs with script attribution +- `loafsWithForcedStyleAndLayout` - Count of LoAFs with native forced style/layout attribution +- `forcedReflowCount` - Deprecated compatibility alias for `loafsWithForcedStyleAndLayout` - `lastLoaf` - Details of most recent LoAF (for real-time debugging) - `worstLoaf` - Details of longest LoAF (for debugging) - `duration`, `blockingDuration`, `renderStart`, `styleAndLayoutStart` - - `scriptCount`, `topScript` (source URL, function name, invoker type) + - `scriptCount`, `forcedStyleAndLayoutDuration`, `topScript` (source URL, function name, invoker type) ### Collection Method: Long Animation Frames API **Type:** Optimal βœ… @@ -478,46 +479,9 @@ this.#styleObserver = new MutationObserver(mutations => { - Only detects inline style changes, not stylesheet modifications - Cannot detect CSSOM manipulations via `CSSStyleSheet` API - Thrashing correlation is approximate +- Observation is scoped to the active story root ---- - -## ForcedReflowCollector - -**File:** [forced-reflow-collector.ts](./forced-reflow-collector.ts) - -### Metrics -- `forcedReflowCount` - Reads of layout properties after style writes - -### Collection Method: Property Getter Instrumentation -**Type:** Heuristic - -```typescript -// Patches HTMLElement.prototype property getters -Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { - get() { - if (collector.#layoutDirty) { - collector.#forcedReflowCount++ - collector.#layoutDirty = false - } - return originalGetter.call(this) - }, - configurable: true, -}) -``` - -**Why this approach:** -- No direct browser API for detecting forced synchronous layout -- Layout-triggering properties (offset*, scroll*, client*) force reflow when read after style changes -- `layoutDirty` flag set by StyleMutationCollector on style writes - -**Limitations:** -- Only detects reflows from JavaScript property access -- Does not detect reflows from CSS-only changes -- May have false positives if layout was already computed -- Property patching has slight performance overhead - -**Tracked properties:** -`offsetTop`, `offsetLeft`, `offsetWidth`, `offsetHeight`, `scrollTop`, `scrollLeft`, `scrollWidth`, `scrollHeight`, `clientTop`, `clientLeft`, `clientWidth`, `clientHeight` +Forced style/layout evidence comes from `PerformanceScriptTiming.forcedStyleAndLayoutDuration` in `LongAnimationFrameCollector`. The addon does not patch DOM or CSS prototypes. LoAF attribution is currently available in Chrome/Edge 123+. --- 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 index ee884e2..fcd0938 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/attribution.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/attribution.browser.test.ts @@ -1,6 +1,11 @@ import {describe, expect, it} from 'vitest' -import {addBoundedAttribution, ATTRIBUTION_ENTRY_LIMIT, limitAttributionString} from '../attribution' +import { + addBoundedAttribution, + ATTRIBUTION_ENTRY_LIMIT, + getElementSelector, + limitAttributionString, +} from '../attribution' describe('attribution bounds', () => { it('retains only the most recent bounded entries', () => { @@ -18,4 +23,29 @@ describe('attribution bounds', () => { expect(limitAttributionString('abcdef', 'unknown', 4)).toBe('abcd') expect(limitAttributionString('', 'unknown', 4)).toBe('unkn') }) + + it.each([ + ['id', 'section', 'account:details[open]'], + ['elementtiming', 'article', 'hero"image'], + ['class', 'div', 'sm:w-1/2'], + ])('generates a selectable selector from a special-character %s', (source, tagName, value) => { + const root = document.createElement('div') + const element = document.createElement(tagName) + if (source === 'id') element.id = value + if (source === 'elementtiming') element.setAttribute('elementtiming', value) + if (source === 'class') element.className = value + root.appendChild(element) + + expect(root.querySelector(getElementSelector(element))).toBe(element) + }) + + it('falls back to a valid selector when an escaped id exceeds the attribution bound', () => { + const root = document.createElement('div') + const element = document.createElement('button') + element.id = ':'.repeat(300) + root.appendChild(element) + + expect(getElementSelector(element)).toBe('button') + expect(root.querySelector(getElementSelector(element))).toBe(element) + }) }) 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 12dcee5..11b48ec 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 @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {CollectorManager} from '../../collectors/collector-manager' import {DOM_MUTATION_SAMPLE_INTERVAL_MS} from '../../collectors/style-mutation-collector' +import {OverheadTelemetry} from '../../core/overhead-telemetry' import type {RenderInfo} from '../../core/performance-types' /** @@ -56,7 +57,6 @@ describe('CollectorManager', () => { expect(manager.collectors.layoutShift).toBeDefined() expect(manager.collectors.memory).toBeDefined() expect(manager.collectors.style).toBeDefined() - expect(manager.collectors.reflow).toBeDefined() expect(manager.collectors.react).toBeDefined() expect(manager.collectors.paint).toBeDefined() expect(manager.collectors.elementTiming).toBeDefined() @@ -66,9 +66,16 @@ describe('CollectorManager', () => { expect(manager.isRunning).toBe(false) }) - it('wires up style β†’ reflow dependency', () => { - // The style collector should have onLayoutDirty set - expect(manager.collectors.style.onLayoutDirty).toBeDefined() + it('does not patch global DOM prototypes when started', () => { + const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') + const setPropertyDescriptor = Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, 'setProperty') + + manager.start() + + expect(Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')).toEqual(offsetWidthDescriptor) + expect(Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, 'setProperty')).toEqual( + setPropertyDescriptor, + ) }) }) @@ -171,6 +178,23 @@ describe('CollectorManager', () => { }) describe('observeContainer', () => { + it('scopes style and paint collectors to the observed container', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const styleSpy = vi.spyOn(manager.collectors.style, 'setContainer') + const paintSpy = vi.spyOn(manager.collectors.paint, 'setContainer') + + const cleanup = manager.observeContainer(container) + + expect(styleSpy).toHaveBeenCalledWith(container) + expect(paintSpy).toHaveBeenCalledWith(container) + + cleanup() + expect(styleSpy).toHaveBeenLastCalledWith(null) + expect(paintSpy).toHaveBeenLastCalledWith(null) + container.remove() + }) + it('counts elements in container on initial call', () => { const container = document.createElement('div') container.innerHTML = '' @@ -297,6 +321,7 @@ describe('CollectorManager', () => { expect(metrics).toHaveProperty('loafSupported') expect(metrics).toHaveProperty('loafCount') expect(metrics).toHaveProperty('totalLoafBlockingDuration') + expect(metrics).toHaveProperty('loafsWithForcedStyleAndLayout') // Layout shift metrics expect(metrics).toHaveProperty('layoutShiftScore') @@ -415,6 +440,7 @@ describe('CollectorManager', () => { expect(metrics.scriptResourceLoadTime).toBe(deprecatedMetrics.scriptEvalTime) expect(metrics.layerPromotionCandidates).toBe(deprecatedMetrics.compositorLayers) expect(metrics.inferredDroppedFrames).toBe(deprecatedMetrics.droppedFrames) + expect(metrics.loafsWithForcedStyleAndLayout).toBe(deprecatedMetrics.forcedReflowCount) expect(deprecatedMetrics.domMutationsPerFrame).toBe(3) expect(metrics.domMutationsPerSecond).toBe((3 * 1000) / DOM_MUTATION_SAMPLE_INTERVAL_MS) }) @@ -428,6 +454,23 @@ describe('CollectorManager', () => { expect(metrics.inputLatency).toBe(Math.round(metrics.inputLatency * 10) / 10) expect(metrics.pointerFrameInterval).toBe(Math.round(metrics.pointerFrameInterval * 10) / 10) }) + + it('records compute duration only when internal telemetry is supplied', () => { + const telemetry = new OverheadTelemetry() + const instrumentedManager = new CollectorManager({overheadTelemetry: telemetry}) + + instrumentedManager.computeMetrics() + instrumentedManager.start() + rafCallback?.(16.67) + instrumentedManager.reportRender(createRenderInfo()) + + const snapshot = telemetry.snapshot() + expect(snapshot.computeMetrics.count).toBe(1) + expect(snapshot.callbacks['frame.raf']?.count).toBe(1) + expect(snapshot.callbacks['react.profiler']?.count).toBe(1) + expect(snapshot.pendingWork['frame.raf']).toEqual({current: 1, peak: 1}) + instrumentedManager.stop() + }) }) describe('integration', () => { @@ -458,16 +501,5 @@ describe('CollectorManager', () => { // Update count is reset expect(resetMetrics.reactPostMountUpdateCount).toBe(0) }) - - it('style β†’ reflow dependency is wired correctly', () => { - manager.start() - - // Trigger layout dirty via style collector - manager.collectors.style.onLayoutDirty?.() - - // Reflow collector should have been notified - // (we can't easily verify internal state, but at least verify no errors) - expect(manager.collectors.reflow).toBeDefined() - }) }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/forced-reflow-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/forced-reflow-collector.browser.test.ts deleted file mode 100644 index 58a8f9a..0000000 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/forced-reflow-collector.browser.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it} from 'vitest' - -import {ForcedReflowCollector} from '../../collectors/forced-reflow-collector' - -describe('ForcedReflowCollector', () => { - let collector: ForcedReflowCollector - - beforeEach(() => { - collector = new ForcedReflowCollector() - }) - - afterEach(() => { - collector.stop() - }) - - describe('getMetrics', () => { - it('returns initial metrics', () => { - const metrics = collector.getMetrics() - expect(metrics.forcedReflowCount).toBe(0) - }) - }) - - describe('markLayoutDirty and reflow detection', () => { - it('counts forced reflow when reading layout after marking dirty', () => { - collector.start() - - // Mark layout dirty (simulates style write) - collector.markLayoutDirty() - - // Create a test element - const el = document.createElement('div') - document.body.appendChild(el) - - // Reading layout property should trigger forced reflow count - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - el.offsetWidth - - const metrics = collector.getMetrics() - expect(metrics.forcedReflowCount).toBe(1) - - document.body.removeChild(el) - }) - - it('does not count when layout is not dirty', () => { - collector.start() - - const el = document.createElement('div') - document.body.appendChild(el) - - // Reading without marking dirty should not count - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - el.offsetWidth - - const metrics = collector.getMetrics() - expect(metrics.forcedReflowCount).toBe(0) - - document.body.removeChild(el) - }) - }) - - describe('reset', () => { - it('clears count', () => { - collector.start() - collector.markLayoutDirty() - - const el = document.createElement('div') - document.body.appendChild(el) - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - el.offsetWidth - - collector.reset() - - const metrics = collector.getMetrics() - expect(metrics.forcedReflowCount).toBe(0) - - document.body.removeChild(el) - }) - }) - - describe('stop', () => { - it('restores original getters when last collector stops', () => { - const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') - - collector.start() - collector.stop() - - const currentDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(currentDescriptor?.get).toBe(originalDescriptor?.get) - }) - - it('keeps patched getters when other collectors are active', () => { - const collector2 = new ForcedReflowCollector() - - collector.start() - collector2.start() - - // First collector stops - collector.stop() - - // Getter should still be patched for collector2 - collector2.markLayoutDirty() - const el = document.createElement('div') - document.body.appendChild(el) - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - el.offsetWidth - - const metrics = collector2.getMetrics() - expect(metrics.forcedReflowCount).toBe(1) - - document.body.removeChild(el) - collector2.stop() - }) - }) -}) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/input-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/input-collector.browser.test.ts index 6fdd67b..f8d2eef 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/input-collector.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/input-collector.browser.test.ts @@ -1,13 +1,16 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {OverheadTelemetry} from '../../core/overhead-telemetry' import {InputCollector} from '../input-collector' describe('InputCollector', () => { let collector: InputCollector + let telemetry: OverheadTelemetry beforeEach(() => { vi.useFakeTimers() - collector = new InputCollector() + telemetry = new OverheadTelemetry() + collector = new InputCollector(telemetry) }) afterEach(() => { @@ -52,6 +55,72 @@ describe('InputCollector', () => { // pointermove is still tracked for hover responsiveness (not covered by INP) expect(addSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)) }) + + it('coalesces pointer bursts into one cancellable RAF pipeline', () => { + const callbacks = new Map() + let nextRafId = 1 + const requestSpy = vi.fn((callback: FrameRequestCallback) => { + const rafId = nextRafId++ + callbacks.set(rafId, callback) + return rafId + }) + const cancelSpy = vi.fn((rafId: number) => { + callbacks.delete(rafId) + }) + vi.stubGlobal('requestAnimationFrame', requestSpy) + vi.stubGlobal('cancelAnimationFrame', cancelSpy) + collector.start() + + window.dispatchEvent(new PointerEvent('pointermove')) + window.dispatchEvent(new PointerEvent('pointermove')) + expect(requestSpy).toHaveBeenCalledTimes(1) + + callbacks.get(1)?.(16) + callbacks.delete(1) + expect(requestSpy).toHaveBeenCalledTimes(2) + + window.dispatchEvent(new PointerEvent('pointermove')) + window.dispatchEvent(new PointerEvent('pointermove')) + expect(requestSpy).toHaveBeenCalledTimes(2) + + callbacks.get(2)?.(32) + callbacks.delete(2) + expect(requestSpy).toHaveBeenCalledTimes(3) + + collector.stop() + expect(cancelSpy).toHaveBeenCalledWith(3) + expect(callbacks).toHaveLength(0) + expect(telemetry.snapshot().callbacks['input.pointer-raf']?.count).toBe(1) + expect(telemetry.snapshot().callbacks['input.paint-raf']?.count).toBe(1) + expect(telemetry.snapshot().pendingWork['input.pointer-raf']).toEqual({current: 0, peak: 1}) + }) + + it('cancels the paint RAF when stopped between pointer frames', () => { + const callbacks = new Map() + let nextRafId = 1 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const rafId = nextRafId++ + callbacks.set(rafId, callback) + return rafId + }) + const cancelSpy = vi.fn((rafId: number) => { + callbacks.delete(rafId) + }) + vi.stubGlobal('cancelAnimationFrame', cancelSpy) + collector.start() + + window.dispatchEvent(new PointerEvent('pointermove')) + callbacks.get(1)?.(16) + callbacks.delete(1) + expect(callbacks.has(2)).toBe(true) + + collector.stop() + + expect(cancelSpy).toHaveBeenCalledWith(2) + expect(callbacks).toHaveLength(0) + expect(telemetry.snapshot().pendingWork['input.pointer-raf']?.current).toBe(0) + expect(telemetry.snapshot().pendingWork['input.paint-raf']?.current).toBe(0) + }) }) describe('stop', () => { 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 72b19e2..52462bd 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 @@ -41,6 +41,7 @@ describe('LongAnimationFrameCollector', () => { expect(metrics.avgLoafDuration).toBe(0) expect(metrics.p95LoafDuration).toBe(0) expect(metrics.loafsWithScripts).toBe(0) + expect(metrics.loafsWithForcedStyleAndLayout).toBe(0) expect(metrics.lastLoaf).toBeNull() expect(metrics.worstLoaf).toBeNull() }) @@ -122,6 +123,7 @@ describe('LongAnimationFrameCollector', () => { forcedStyleAndLayoutDuration: 15, topScript: {sourceURL: '/story.js', forcedStyleAndLayoutDuration: 12}, }) + expect(collector.getMetrics().loafsWithForcedStyleAndLayout).toBe(1) }) describe('start/stop', () => { 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 d6d70fa..167b07f 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 {OverheadTelemetry} from '../../core/overhead-telemetry' import {ATTRIBUTION_ENTRY_LIMIT} from '../attribution' import {PaintCollector} from '../paint-collector' @@ -39,9 +40,11 @@ function waitForIdle(): Promise { describe('PaintCollector', () => { let collector: PaintCollector + let container: HTMLElement let paintObserverCallback: PerformanceObserverCallback | null = null let resourceObserverCallback: PerformanceObserverCallback | null = null let mockDisconnect = vi.fn() + let telemetry: OverheadTelemetry beforeEach(() => { mockDisconnect = vi.fn() @@ -70,11 +73,16 @@ describe('PaintCollector', () => { }, ) - collector = new PaintCollector() + container = document.createElement('div') + document.body.appendChild(container) + telemetry = new OverheadTelemetry() + collector = new PaintCollector(telemetry) + collector.setContainer(container) }) afterEach(() => { collector.stop() + container.remove() vi.unstubAllGlobals() }) @@ -252,33 +260,33 @@ describe('PaintCollector', () => { it('detects elements with will-change via initial scan', async () => { const el = document.createElement('div') el.style.willChange = 'transform' - document.body.appendChild(el) + container.appendChild(el) collector.start() await waitUntil(() => collector.getMetrics().compositorLayers !== null) expect(collector.getMetrics().compositorLayers).toBeGreaterThanOrEqual(1) - document.body.removeChild(el) + container.removeChild(el) }) it('detects elements with 3D transforms', async () => { const el = document.createElement('div') el.style.transform = 'translateZ(1px)' - document.body.appendChild(el) + container.appendChild(el) collector.start() await waitUntil(() => collector.getMetrics().compositorLayers !== null) expect(collector.getMetrics().compositorLayers).toBeGreaterThanOrEqual(1) - document.body.removeChild(el) + container.removeChild(el) }) it('does not count 2D transforms', async () => { const el = document.createElement('div') el.style.transform = 'translateX(10px)' - document.body.appendChild(el) + container.appendChild(el) collector.start() await waitUntil(() => collector.getMetrics().compositorLayers !== null) @@ -286,7 +294,7 @@ describe('PaintCollector', () => { // A 2D transform alone is not treated as a layer-promotion candidate. expect(collector.getMetrics().compositorLayers).not.toBeNull() - document.body.removeChild(el) + container.removeChild(el) }) it('defers scan to idle callback instead of running synchronously', () => { @@ -296,6 +304,20 @@ describe('PaintCollector', () => { expect(collector.getMetrics().compositorLayers).toBeNull() }) + it('cancels a pending idle scan when stopped', () => { + const cancelSpy = vi.fn() + vi.stubGlobal('requestIdleCallback', () => 42) + vi.stubGlobal('cancelIdleCallback', cancelSpy) + + collector.start() + expect(telemetry.snapshot().pendingWork['paint.idle-scan']?.current).toBe(1) + + collector.stop() + + expect(cancelSpy).toHaveBeenCalledWith(42) + expect(telemetry.snapshot().pendingWork['paint.idle-scan']?.current).toBe(0) + }) + it('incrementally tracks added elements via MutationObserver', async () => { collector.start() await waitUntil(() => collector.getMetrics().compositorLayers !== null) @@ -304,18 +326,18 @@ describe('PaintCollector', () => { const el = document.createElement('div') el.style.willChange = 'transform' - document.body.appendChild(el) + container.appendChild(el) await waitUntil(() => collector.getMetrics().compositorLayers === baseline + 1) expect(collector.getMetrics().compositorLayers).toBe(baseline + 1) - document.body.removeChild(el) + container.removeChild(el) }) it('decrements count when compositor-layer elements are removed', async () => { const el = document.createElement('div') el.style.willChange = 'transform' - document.body.appendChild(el) + container.appendChild(el) collector.start() await waitUntil(() => collector.getMetrics().compositorLayers !== null) @@ -323,7 +345,7 @@ describe('PaintCollector', () => { const countWithEl = collector.getMetrics().compositorLayers ?? 0 expect(countWithEl).toBeGreaterThanOrEqual(1) - document.body.removeChild(el) + container.removeChild(el) await waitUntil(() => collector.getMetrics().compositorLayers === countWithEl - 1) expect(collector.getMetrics().compositorLayers).toBe(countWithEl - 1) @@ -331,7 +353,7 @@ describe('PaintCollector', () => { it('tracks style attribute changes on existing elements', async () => { const el = document.createElement('div') - document.body.appendChild(el) + container.appendChild(el) collector.start() await waitUntil(() => collector.getMetrics().compositorLayers !== null) @@ -348,7 +370,80 @@ describe('PaintCollector', () => { expect(collector.getMetrics().compositorLayers).toBe(baseline) - document.body.removeChild(el) + container.removeChild(el) + }) + + it('ignores layer candidates outside the story container', async () => { + const outsideElement = document.createElement('div') + outsideElement.style.willChange = 'transform' + document.body.appendChild(outsideElement) + + collector.start() + await waitUntil(() => collector.getMetrics().compositorLayers !== null) + + expect(collector.getMetrics().compositorLayers).toBe(0) + outsideElement.remove() + }) + + it('chunks initial layer scans across idle callbacks', () => { + const idleCallbacks: IdleRequestCallback[] = [] + vi.stubGlobal('requestIdleCallback', (callback: IdleRequestCallback) => { + idleCallbacks.push(callback) + return idleCallbacks.length + }) + for (let index = 0; index < 75; index++) { + const element = document.createElement('div') + element.style.willChange = 'transform' + container.appendChild(element) + } + + collector.start() + expect(idleCallbacks).toHaveLength(1) + + idleCallbacks.shift()?.({didTimeout: false, timeRemaining: () => 10}) + expect(collector.getMetrics().compositorLayers).toBeNull() + expect(idleCallbacks).toHaveLength(1) + + idleCallbacks.shift()?.({didTimeout: false, timeRemaining: () => 10}) + expect(collector.getMetrics().compositorLayers).toBe(75) + expect(telemetry.snapshot().callbacks['paint.layer-scan']?.count).toBe(2) + expect(telemetry.snapshot().scans['paint.layer-elements']).toBe(75) + expect(telemetry.snapshot().pendingWork['paint.idle-scan']).toEqual({current: 0, peak: 1}) + }) + + it('keeps the story root out of descendant layer counts', async () => { + collector.start() + await waitUntil(() => collector.getMetrics().compositorLayers === 0) + + container.style.willChange = 'transform' + await waitForIdle() + + expect(collector.getMetrics().compositorLayers).toBe(0) + }) + + it('requeues an element that leaves and returns during a chunked scan', async () => { + const idleCallbacks: IdleRequestCallback[] = [] + vi.stubGlobal('requestIdleCallback', (callback: IdleRequestCallback) => { + idleCallbacks.push(callback) + return idleCallbacks.length + }) + const target = document.createElement('div') + container.appendChild(target) + for (let index = 0; index < 74; index++) { + container.appendChild(document.createElement('div')) + } + + collector.start() + idleCallbacks.shift()?.({didTimeout: false, timeRemaining: () => 10}) + expect(collector.getMetrics().compositorLayers).toBeNull() + + target.remove() + target.style.willChange = 'transform' + container.appendChild(target) + await new Promise(resolve => setTimeout(resolve, 0)) + + idleCallbacks.shift()?.({didTimeout: false, timeRemaining: () => 10}) + expect(collector.getMetrics().compositorLayers).toBe(1) }) }) @@ -383,7 +478,7 @@ describe('PaintCollector', () => { const el = document.createElement('div') el.style.willChange = 'transform' - document.body.appendChild(el) + container.appendChild(el) await waitUntil(() => (collector.getMetrics().compositorLayers ?? 0) >= 1) collector.reset() @@ -394,7 +489,7 @@ describe('PaintCollector', () => { // After idle scan completes, count is restored expect(collector.getMetrics().compositorLayers).toBeGreaterThanOrEqual(1) - document.body.removeChild(el) + container.removeChild(el) }) }) @@ -416,13 +511,13 @@ describe('PaintCollector', () => { const el = document.createElement('div') el.style.willChange = 'transform' - document.body.appendChild(el) + container.appendChild(el) await waitForIdle() // Count should not change after stop await expect.poll(() => collector.getMetrics().compositorLayers).toBe(countAfterStop) - document.body.removeChild(el) + container.removeChild(el) }) }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts b/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts index 18262d3..8d4bb03 100644 --- a/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts +++ b/packages/storybook-addon-performance-panel/collectors/__tests__/style-mutation-collector.browser.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {OverheadTelemetry} from '../../core/overhead-telemetry' import {StyleMutationCollector} from '../style-mutation-collector' // Helper to wait for MutationObserver callbacks (microtask) @@ -7,13 +8,20 @@ const flushMutations = () => new Promise(resolve => setTimeout(resolve, 0)) describe('StyleMutationCollector', () => { let collector: StyleMutationCollector + let container: HTMLElement + let telemetry: OverheadTelemetry beforeEach(() => { - collector = new StyleMutationCollector() + container = document.createElement('div') + document.body.appendChild(container) + telemetry = new OverheadTelemetry() + collector = new StyleMutationCollector(telemetry) + collector.setContainer(container) }) afterEach(() => { collector.stop() + container.remove() }) describe('getMetrics', () => { @@ -31,22 +39,24 @@ describe('StyleMutationCollector', () => { collector.start() const el = document.createElement('div') - document.body.appendChild(el) + container.appendChild(el) el.style.color = 'red' await flushMutations() const metrics = collector.getMetrics() expect(metrics.styleWrites).toBe(1) + expect(telemetry.snapshot().callbacks['style.mutations']?.count).toBeGreaterThan(0) + expect(telemetry.snapshot().scans['style.mutation-records']).toBeGreaterThan(0) - document.body.removeChild(el) + container.removeChild(el) }) it('counts CSS variable changes', async () => { collector.start() const el = document.createElement('div') - document.body.appendChild(el) + container.appendChild(el) el.style.setProperty('--my-color', 'blue') await flushMutations() @@ -54,7 +64,7 @@ describe('StyleMutationCollector', () => { const metrics = collector.getMetrics() expect(metrics.cssVarChanges).toBeGreaterThanOrEqual(1) - document.body.removeChild(el) + container.removeChild(el) }) }) @@ -63,7 +73,7 @@ describe('StyleMutationCollector', () => { collector.start() const parent = document.createElement('div') - document.body.appendChild(parent) + container.appendChild(parent) parent.appendChild(document.createElement('span')) parent.appendChild(document.createElement('span')) @@ -75,25 +85,7 @@ describe('StyleMutationCollector', () => { collector.stop() }).not.toThrow() - document.body.removeChild(parent) - }) - }) - - describe('onLayoutDirty callback', () => { - it('calls callback on style write', async () => { - const onLayoutDirty = vi.fn() - collector.onLayoutDirty = onLayoutDirty - collector.start() - - const el = document.createElement('div') - document.body.appendChild(el) - - el.style.width = '100px' - await flushMutations() - - expect(onLayoutDirty).toHaveBeenCalled() - - document.body.removeChild(el) + container.removeChild(parent) }) }) @@ -106,7 +98,7 @@ describe('StyleMutationCollector', () => { collector.start() const el = document.createElement('div') - document.body.appendChild(el) + container.appendChild(el) el.style.width = '100px' await flushMutations() @@ -120,7 +112,7 @@ describe('StyleMutationCollector', () => { const metrics = collector.getMetrics() expect(metrics.thrashingScore).toBe(1) - document.body.removeChild(el) + container.removeChild(el) vi.restoreAllMocks() }) @@ -132,7 +124,7 @@ describe('StyleMutationCollector', () => { collector.start() const el = document.createElement('div') - document.body.appendChild(el) + container.appendChild(el) el.style.width = '100px' await flushMutations() @@ -145,7 +137,7 @@ describe('StyleMutationCollector', () => { const metrics = collector.getMetrics() expect(metrics.thrashingScore).toBe(0) - document.body.removeChild(el) + container.removeChild(el) vi.restoreAllMocks() }) }) @@ -155,7 +147,7 @@ describe('StyleMutationCollector', () => { collector.start() const el = document.createElement('div') - document.body.appendChild(el) + container.appendChild(el) el.style.color = 'red' await flushMutations() @@ -168,7 +160,22 @@ describe('StyleMutationCollector', () => { expect(metrics.domMutationFrames).toEqual([]) expect(metrics.thrashingScore).toBe(0) - document.body.removeChild(el) + container.removeChild(el) + }) + + it('ignores style changes outside the story container', async () => { + collector.start() + const storyElement = document.createElement('div') + const outsideElement = document.createElement('div') + container.appendChild(storyElement) + document.body.appendChild(outsideElement) + + storyElement.style.color = 'red' + outsideElement.style.color = 'blue' + await flushMutations() + + expect(collector.getMetrics().styleWrites).toBe(1) + outsideElement.remove() }) }) diff --git a/packages/storybook-addon-performance-panel/collectors/attribution.ts b/packages/storybook-addon-performance-panel/collectors/attribution.ts index 7cba854..59d5cd4 100644 --- a/packages/storybook-addon-performance-panel/collectors/attribution.ts +++ b/packages/storybook-addon-performance-panel/collectors/attribution.ts @@ -9,23 +9,29 @@ export function limitAttributionString(value: string, fallback: string, maxLengt return normalized.length <= maxLength ? normalized : normalized.slice(0, maxLength) } +function boundedSelector(selector: string): string | null { + return selector.length <= ATTRIBUTION_SELECTOR_MAX_LENGTH ? selector : null +} + 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 idSelector = boundedSelector(`#${CSS.escape(element.id)}`) + if (idSelector) return idSelector } const timing = element.getAttribute('elementtiming') if (timing) { - return limitAttributionString(`[elementtiming="${timing}"]`, 'unknown', ATTRIBUTION_SELECTOR_MAX_LENGTH) + const timingSelector = boundedSelector(`[elementtiming="${CSS.escape(timing)}"]`) + if (timingSelector) return timingSelector } const className = typeof element.className === 'string' ? element.className : '' - const classes = className.split(/\s+/).filter(Boolean).slice(0, 2).join('.') + const classes = className.split(/\s+/).filter(Boolean).slice(0, 2).map(CSS.escape).join('.') const selector = `${element.tagName.toLowerCase()}${classes ? `.${classes}` : ''}` - return limitAttributionString(selector, 'unknown', ATTRIBUTION_SELECTOR_MAX_LENGTH) + return boundedSelector(selector) ?? 'unknown' } export function addBoundedAttribution(items: T[], item: T): void { diff --git a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts index d22c249..5b7622c 100644 --- a/packages/storybook-addon-performance-panel/collectors/collector-manager.ts +++ b/packages/storybook-addon-performance-panel/collectors/collector-manager.ts @@ -6,10 +6,10 @@ * handles orchestration and ensures consistent state across all collectors. */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import type {PerformanceMetrics, ReactMetrics, RenderInfo} from '../core/performance-types' import {SPARKLINE_HISTORY_SIZE} from './constants' import {ElementTimingCollector} from './element-timing-collector' -import {ForcedReflowCollector} from './forced-reflow-collector' import type {FrameTimingMetrics} from './frame-timing-collector' import {FrameTimingCollector} from './frame-timing-collector' import {InputCollector} from './input-collector' @@ -49,7 +49,6 @@ export interface DecoratorState { * * Benefits: * - Single point of control for starting/stopping all collectors - * - Ensures proper initialization order (e.g., style β†’ reflow dependency) * - Reduces boilerplate in PerformanceProvider * - Makes testing easier by providing a single mock point * @@ -69,7 +68,6 @@ export class CollectorManager { readonly layoutShift: LayoutShiftCollector readonly memory: MemoryCollector readonly style: StyleMutationCollector - readonly reflow: ForcedReflowCollector readonly react: ReactProfilerCollector readonly paint: PaintCollector readonly elementTiming: ElementTimingCollector @@ -78,6 +76,7 @@ export class CollectorManager { #running = false #state: DecoratorState #lastCleanedStoryId: string | null = null + #overheadTelemetry: OverheadTelemetry | undefined /** * Create a new CollectorManager. @@ -86,29 +85,27 @@ export class CollectorManager { */ constructor({ onProfilerUpdate, - }: {onProfilerUpdate?: (storyId: string, id: string, metrics: ReactMetrics) => void} = {}) { + overheadTelemetry, + }: { + onProfilerUpdate?: (storyId: string, id: string, metrics: ReactMetrics) => void + overheadTelemetry?: OverheadTelemetry + } = {}) { this.#state = createInitialState() + this.#overheadTelemetry = overheadTelemetry this.collectors = { - // Order matters: style/reflow should be early (they patch DOM APIs) - style: new StyleMutationCollector(), - reflow: new ForcedReflowCollector(), + style: new StyleMutationCollector(overheadTelemetry), frame: new FrameTimingCollector(delta => { this.collectors.style.checkThrashing(delta) - }), - input: new InputCollector(), - mainThread: new MainThreadCollector(), - loaf: new LongAnimationFrameCollector(), - layoutShift: new LayoutShiftCollector(), + }, overheadTelemetry), + input: new InputCollector(overheadTelemetry), + mainThread: new MainThreadCollector(overheadTelemetry), + loaf: new LongAnimationFrameCollector(overheadTelemetry), + layoutShift: new LayoutShiftCollector(overheadTelemetry), memory: new MemoryCollector(), - react: new ReactProfilerCollector(), - paint: new PaintCollector(), - elementTiming: new ElementTimingCollector(), - } - - // Wire up style β†’ reflow dependency - this.collectors.style.onLayoutDirty = () => { - this.collectors.reflow.markLayoutDirty() + react: new ReactProfilerCollector(overheadTelemetry), + paint: new PaintCollector(overheadTelemetry), + elementTiming: new ElementTimingCollector(overheadTelemetry), } // Wire up profiler update callback with automatic cleanup @@ -238,9 +235,15 @@ export class CollectorManager { let countTimeout: ReturnType | null = null let pendingCount = false + this.collectors.style.setContainer(container) + this.collectors.paint.setContainer(container) + const countElements = () => { - this.#state.domElements = container.querySelectorAll('*').length + const elementCount = container.querySelectorAll('*').length + this.#state.domElements = elementCount + this.#overheadTelemetry?.recordScan('manager.dom-elements', elementCount + 1) pendingCount = false + this.#overheadTelemetry?.setPendingWork('manager.dom-count', 0) } const scheduleCount = () => { @@ -248,18 +251,32 @@ export class CollectorManager { if (!pendingCount) { pendingCount = true countTimeout = setTimeout(countElements, 500) + this.#overheadTelemetry?.setPendingWork('manager.dom-count', 1) } } // Initial count countElements() - const observer = new MutationObserver(scheduleCount) + const observer = new MutationObserver(mutations => { + const processMutations = () => { + this.#overheadTelemetry?.recordScan('manager.mutation-records', mutations.length) + scheduleCount() + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('manager.container-mutations', processMutations) + } else { + processMutations() + } + }) observer.observe(container, {childList: true, subtree: true}) return () => { observer.disconnect() if (countTimeout) clearTimeout(countTimeout) + this.#overheadTelemetry?.setPendingWork('manager.dom-count', 0) + this.collectors.style.setContainer(null) + this.collectors.paint.setContainer(null) } } @@ -282,8 +299,9 @@ export class CollectorManager { * @returns Processed metrics ready for panel display */ computeMetrics(): PerformanceMetrics { + const computeStartTime = this.#overheadTelemetry ? performance.now() : 0 const state = this.#state - const {frame, input, mainThread, loaf, layoutShift, memory, style, reflow, react, paint, elementTiming} = + const {frame, input, mainThread, loaf, layoutShift, memory, style, react, paint, elementTiming} = Object.fromEntries(Object.entries(this.collectors).map(([k, v]) => [k, v.getMetrics()])) as { [K in keyof typeof this.collectors]: ReturnType<(typeof this.collectors)[K]['getMetrics']> } @@ -339,6 +357,7 @@ export class CollectorManager { avgLoafDuration: loaf.avgLoafDuration, p95LoafDuration: loaf.p95LoafDuration, loafsWithScripts: loaf.loafsWithScripts, + loafsWithForcedStyleAndLayout: loaf.loafsWithForcedStyleAndLayout, lastLoaf: loaf.lastLoaf, worstLoaf: loaf.worstLoaf, // Continue with other metrics @@ -366,7 +385,8 @@ export class CollectorManager { reactPostMountMaxDuration: react.reactPostMountMaxDuration, renderCascades: react.nestedUpdateCount, domElements: state.domElements, - forcedReflowCount: reflow.forcedReflowCount, + // Deprecated compatibility field; global forced-reflow instrumentation was removed. + forcedReflowCount: loaf.loafsWithForcedStyleAndLayout, eventListenerCount: 0, // Not currently tracked by collectors observerCount: 0, // Not currently tracked by collectors cssVarChanges: style.cssVarChanges, @@ -399,6 +419,9 @@ export class CollectorManager { })), } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.recordComputeMetrics(performance.now() - computeStartTime) + } return metrics } } 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 3248987..4c644d3 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,7 @@ * @see https://web.dev/articles/custom-metrics#element-timing-api */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import { addBoundedAttribution, ATTRIBUTION_LABEL_MAX_LENGTH, @@ -94,8 +95,10 @@ export class ElementTimingCollector implements MetricCollector { - for (const entry of list.getEntries()) { - this.#processEntry(entry as PerformanceElementTiming) + const processEntries = () => { + for (const entry of list.getEntries()) { + this.#processEntry(entry as PerformanceElementTiming) + } + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('element-timing.entries', processEntries) + } else { + processEntries() } }) diff --git a/packages/storybook-addon-performance-panel/collectors/forced-reflow-collector.ts b/packages/storybook-addon-performance-panel/collectors/forced-reflow-collector.ts deleted file mode 100644 index a57630f..0000000 --- a/packages/storybook-addon-performance-panel/collectors/forced-reflow-collector.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * @fileoverview Forced synchronous layout (reflow) detection collector - * @module collectors/ForcedReflowCollector - */ - -import type {MetricCollector} from './types' - -export interface ReflowMetrics { - forcedReflowCount: number -} - -/** - * Detects forced synchronous layout (reflows). - * - * Instruments layout-triggering property getters AND layout-affecting - * CSS property setters to detect when layout properties are read - * immediately after style writes (synchronously, within the same task). - */ -export class ForcedReflowCollector implements MetricCollector { - #forcedReflowCount = 0 - #layoutDirty = false - #dirtyTimeout: ReturnType | null = null - - // Shared registry for property patching - tracks active collectors and original descriptors - static #registry: { - initialized: boolean - originalGetters: Map - originalStyleSetters: Map - originalSetProperty: typeof CSSStyleDeclaration.prototype.setProperty | null - activeCollectors: Set - currentCollector: ForcedReflowCollector | null - } | null = null - - /** Layout-triggering property getters on HTMLElement */ - static readonly #REFLOW_PROPS = [ - 'offsetTop', - 'offsetLeft', - 'offsetWidth', - 'offsetHeight', - 'scrollTop', - 'scrollLeft', - 'scrollWidth', - 'scrollHeight', - 'clientTop', - 'clientLeft', - 'clientWidth', - 'clientHeight', - ] as const - - /** CSS properties whose writes invalidate layout (trigger reflow when subsequently read) */ - static readonly #LAYOUT_STYLE_PROPS = [ - 'width', - 'height', - 'minWidth', - 'minHeight', - 'maxWidth', - 'maxHeight', - 'padding', - 'paddingTop', - 'paddingRight', - 'paddingBottom', - 'paddingLeft', - 'margin', - 'marginTop', - 'marginRight', - 'marginBottom', - 'marginLeft', - 'borderWidth', - 'borderTopWidth', - 'borderRightWidth', - 'borderBottomWidth', - 'borderLeftWidth', - 'display', - 'position', - 'top', - 'left', - 'right', - 'bottom', - 'fontSize', - 'lineHeight', - 'boxSizing', - 'overflow', - ] as const - - markLayoutDirty(): void { - this.#layoutDirty = true - if (this.#dirtyTimeout) clearTimeout(this.#dirtyTimeout) - this.#dirtyTimeout = setTimeout(() => { - this.#layoutDirty = false - }, 0) - } - - start(): void { - // Initialize shared registry if needed - if (!ForcedReflowCollector.#registry) { - ForcedReflowCollector.#registry = { - initialized: false, - originalGetters: new Map(), - originalStyleSetters: new Map(), - originalSetProperty: null, - activeCollectors: new Set(), - currentCollector: null, - } - } - - const registry = ForcedReflowCollector.#registry - registry.activeCollectors.add(this) - registry.currentCollector = this - - if (!registry.initialized) { - registry.initialized = true - - // Instrument layout-triggering getters on HTMLElement - for (const prop of ForcedReflowCollector.#REFLOW_PROPS) { - const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) - if (descriptor?.get) { - registry.originalGetters.set(prop, descriptor) - Object.defineProperty(HTMLElement.prototype, prop, { - get() { - const collector = ForcedReflowCollector.#registry?.currentCollector - if (collector && collector.#layoutDirty) { - collector.#forcedReflowCount++ - collector.#layoutDirty = false - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return descriptor.get?.call(this) - }, - configurable: true, - }) - } - } - - // Instrument layout-affecting CSS property setters for synchronous detection. - // MutationObserver (used by StyleMutationCollector) fires asynchronously, so - // interleaved writeβ†’read patterns within a single task would be missed. - for (const prop of ForcedReflowCollector.#LAYOUT_STYLE_PROPS) { - const descriptor = Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, prop) - if (descriptor?.set) { - registry.originalStyleSetters.set(prop, descriptor) - // eslint-disable-next-line @typescript-eslint/unbound-method -- intentionally capturing to wrap - const origSet = descriptor.set - Object.defineProperty(CSSStyleDeclaration.prototype, prop, { - ...descriptor, - set(value: string) { - ForcedReflowCollector.#registry?.currentCollector?.markLayoutDirty() - origSet.call(this, value) - }, - }) - } - } - - // Instrument CSSStyleDeclaration.setProperty for explicit property setting - // eslint-disable-next-line @typescript-eslint/unbound-method -- intentionally capturing to wrap - const origSetProperty = CSSStyleDeclaration.prototype.setProperty - registry.originalSetProperty = origSetProperty - CSSStyleDeclaration.prototype.setProperty = function (property: string, value: string | null, priority?: string) { - ForcedReflowCollector.#registry?.currentCollector?.markLayoutDirty() - origSetProperty.call(this, property, value, priority ?? '') - } - } - } - - stop(): void { - if (this.#dirtyTimeout) { - clearTimeout(this.#dirtyTimeout) - this.#dirtyTimeout = null - } - - const registry = ForcedReflowCollector.#registry - if (!registry) return - - // Remove this collector from active set - registry.activeCollectors.delete(this) - - // Clear current collector reference if it's this instance - if (registry.currentCollector === this) { - registry.currentCollector = null - } - - // Restore all original descriptors when last collector stops - if (registry.activeCollectors.size === 0 && registry.initialized) { - for (const [prop, descriptor] of registry.originalGetters) { - Object.defineProperty(HTMLElement.prototype, prop, descriptor) - } - registry.originalGetters.clear() - - for (const [prop, descriptor] of registry.originalStyleSetters) { - Object.defineProperty(CSSStyleDeclaration.prototype, prop, descriptor) - } - registry.originalStyleSetters.clear() - - if (registry.originalSetProperty) { - CSSStyleDeclaration.prototype.setProperty = registry.originalSetProperty - registry.originalSetProperty = null - } - - registry.initialized = false - } - } - - reset(): void { - this.#forcedReflowCount = 0 - this.#layoutDirty = false - } - - getMetrics(): ReflowMetrics { - return { - forcedReflowCount: this.#forcedReflowCount, - } - } -} diff --git a/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts b/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts index 880d874..59e74c2 100644 --- a/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/frame-timing-collector.ts @@ -3,6 +3,7 @@ * @module collectors/FrameTimingCollector */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import { FRAME_INACTIVE_GAP_MS, FRAME_INTERVAL_MAX_MS, @@ -60,9 +61,11 @@ export class FrameTimingCollector implements MetricCollector #animationId: number | null = null #onFrame?: (delta: number) => void #running = false + #overheadTelemetry: OverheadTelemetry | undefined - constructor(onFrame?: (delta: number) => void) { + constructor(onFrame?: (delta: number) => void, overheadTelemetry?: OverheadTelemetry) { this.#onFrame = onFrame + this.#overheadTelemetry = overheadTelemetry } start(): void { @@ -73,7 +76,7 @@ export class FrameTimingCollector implements MetricCollector this.#resetCalibration() document.addEventListener('visibilitychange', this.#handleVisibilityChange) if (!document.hidden) { - this.#animationId = requestAnimationFrame(this.#measure) + this.#scheduleFrame() } } @@ -85,6 +88,7 @@ export class FrameTimingCollector implements MetricCollector if (this.#animationId !== null) { cancelAnimationFrame(this.#animationId) this.#animationId = null + this.#overheadTelemetry?.setPendingWork('frame.raf', 0) } this.#lastTime = null } @@ -118,36 +122,57 @@ export class FrameTimingCollector implements MetricCollector #measure = (timestamp: DOMHighResTimeStamp): void => { this.#animationId = null - if (!this.#running || document.hidden) return - - if (this.#lastTime !== null) { - const delta = timestamp - this.#lastTime - - if (delta >= FRAME_INACTIVE_GAP_MS) { - this.#excludedFrameIntervals++ - this.#resetCalibration() - } else if (delta > 0) { - this.#processFrame(delta) - this.#onFrame?.(delta) + this.#overheadTelemetry?.setPendingWork('frame.raf', 0) + const processFrame = () => { + if (!this.#running || document.hidden) return + + if (this.#lastTime !== null) { + const delta = timestamp - this.#lastTime + + if (delta >= FRAME_INACTIVE_GAP_MS) { + this.#excludedFrameIntervals++ + this.#resetCalibration() + } else if (delta > 0) { + this.#processFrame(delta) + this.#onFrame?.(delta) + } } - } - this.#lastTime = timestamp + this.#lastTime = timestamp - this.#animationId = requestAnimationFrame(this.#measure) + this.#scheduleFrame() + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('frame.raf', processFrame) + } else { + processFrame() + } } #handleVisibilityChange = (): void => { - this.#lastTime = null - this.#resetCalibration() - - if (document.hidden) { - if (this.#animationId !== null) { - cancelAnimationFrame(this.#animationId) - this.#animationId = null + const processVisibilityChange = () => { + this.#lastTime = null + this.#resetCalibration() + + if (document.hidden) { + if (this.#animationId !== null) { + cancelAnimationFrame(this.#animationId) + this.#animationId = null + this.#overheadTelemetry?.setPendingWork('frame.raf', 0) + } + } else if (this.#running && this.#animationId === null) { + this.#scheduleFrame() } - } else if (this.#running && this.#animationId === null) { - this.#animationId = requestAnimationFrame(this.#measure) } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('frame.visibility', processVisibilityChange) + } else { + processVisibilityChange() + } + } + + #scheduleFrame(): void { + this.#animationId = requestAnimationFrame(this.#measure) + this.#overheadTelemetry?.setPendingWork('frame.raf', 1) } #processFrame(delta: number): void { diff --git a/packages/storybook-addon-performance-panel/collectors/input-collector.ts b/packages/storybook-addon-performance-panel/collectors/input-collector.ts index 8516c1f..9ce6c0a 100644 --- a/packages/storybook-addon-performance-panel/collectors/input-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/input-collector.ts @@ -16,6 +16,7 @@ * @see https://w3c.github.io/event-timing/ */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import type {InteractionInfo} from '../core/performance-types' import { INPUT_LATENCIES_WINDOW, @@ -149,10 +150,15 @@ export class InputCollector implements MetricCollector { #eventTimingSupported = false /** Entries before this timestamp belong to an earlier story or reset. */ #epochMs = 0 + #pendingPointerEventTime: number | null = null + #pointerRafId: number | null = null + #paintRafId: number | null = null + #overheadTelemetry: OverheadTelemetry | undefined #boundHandlePointerMove: (e: PointerEvent) => void - constructor() { + constructor(overheadTelemetry?: OverheadTelemetry) { + this.#overheadTelemetry = overheadTelemetry this.#boundHandlePointerMove = this.#handlePointerMove.bind(this) // Check if Event Timing API is supported this.#eventTimingSupported = this.#checkEventTimingSupport() @@ -184,8 +190,15 @@ export class InputCollector implements MetricCollector { #startEventTimingObserver(): void { try { this.#eventTimingObserver = new PerformanceObserver(list => { - for (const entry of list.getEntries()) { - this.#processEventTimingEntry(entry as PerformanceEventTiming) + const processEntries = () => { + for (const entry of list.getEntries()) { + this.#processEventTimingEntry(entry as PerformanceEventTiming) + } + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('input.event-timing', processEntries) + } else { + processEntries() } }) @@ -201,12 +214,19 @@ export class InputCollector implements MetricCollector { // Also observe first-input for FID (First Input Delay) // This is guaranteed to report even for fast interactions this.#firstInputObserver = new PerformanceObserver(list => { - const entries = list.getEntries() - const entry = entries.find(candidate => candidate.startTime >= this.#epochMs) - if (entry && this.#firstInputDelay === null) { - const firstInput = entry as PerformanceEventTiming - this.#firstInputDelay = firstInput.processingStart - firstInput.startTime - this.#firstInputType = firstInput.name + const processEntries = () => { + const entries = list.getEntries() + const entry = entries.find(candidate => candidate.startTime >= this.#epochMs) + if (entry && this.#firstInputDelay === null) { + const firstInput = entry as PerformanceEventTiming + this.#firstInputDelay = firstInput.processingStart - firstInput.startTime + this.#firstInputType = firstInput.name + } + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('input.first-input', processEntries) + } else { + processEntries() } }) this.#firstInputObserver.observe({type: 'first-input', buffered: true}) @@ -315,6 +335,7 @@ export class InputCollector implements MetricCollector { stop(): void { this.#commitNativeInteractionCount() window.removeEventListener('pointermove', this.#boundHandlePointerMove) + this.#cancelPendingPointerWork() this.#eventTimingObserver?.disconnect() this.#firstInputObserver?.disconnect() this.#eventTimingObserver = null @@ -322,6 +343,7 @@ export class InputCollector implements MetricCollector { } reset(): void { + this.#cancelPendingPointerWork() this.#inputLatencies = [] this.#maxInputLatency = 0 this.#inputJitter = 0 @@ -401,19 +423,60 @@ export class InputCollector implements MetricCollector { * This captures hover responsiveness which is not measured by INP. */ #handlePointerMove(event: PointerEvent): void { - const eventTime = event.timeStamp - requestAnimationFrame(() => { - const rafTime = performance.now() - const latency = rafTime - eventTime - this.#processInput(latency) - - // Measure the interval between consecutive RAFs after pointer movement. - requestAnimationFrame(() => { - const paintEnd = performance.now() - const paintTime = paintEnd - rafTime - this.#processPaint(paintTime) - }) + this.#pendingPointerEventTime = event.timeStamp + this.#schedulePointerSample() + } + + #schedulePointerSample(): void { + if (this.#pointerRafId !== null || this.#paintRafId !== null) return + + this.#pointerRafId = requestAnimationFrame(() => { + this.#pointerRafId = null + this.#overheadTelemetry?.setPendingWork('input.pointer-raf', 0) + const processPointerFrame = () => { + const eventTime = this.#pendingPointerEventTime + this.#pendingPointerEventTime = null + if (eventTime === null) return + + const rafTime = performance.now() + const latency = rafTime - eventTime + this.#processInput(latency) + + // Measure the interval between consecutive RAFs after pointer movement. + this.#paintRafId = requestAnimationFrame(() => { + this.#paintRafId = null + this.#overheadTelemetry?.setPendingWork('input.paint-raf', 0) + const processPaintFrame = () => { + const paintEnd = performance.now() + const paintTime = paintEnd - rafTime + this.#processPaint(paintTime) + if (this.#pendingPointerEventTime !== null) this.#schedulePointerSample() + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('input.paint-raf', processPaintFrame) + } else { + processPaintFrame() + } + }) + this.#overheadTelemetry?.setPendingWork('input.paint-raf', 1) + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('input.pointer-raf', processPointerFrame) + } else { + processPointerFrame() + } }) + this.#overheadTelemetry?.setPendingWork('input.pointer-raf', 1) + } + + #cancelPendingPointerWork(): void { + if (this.#pointerRafId !== null) cancelAnimationFrame(this.#pointerRafId) + if (this.#paintRafId !== null) cancelAnimationFrame(this.#paintRafId) + this.#pointerRafId = null + this.#paintRafId = null + this.#pendingPointerEventTime = null + this.#overheadTelemetry?.setPendingWork('input.pointer-raf', 0) + this.#overheadTelemetry?.setPendingWork('input.paint-raf', 0) } #processInput(latency: number): void { 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 390e5e5..5d256fe 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,7 @@ * @see https://web.dev/articles/evolving-cls */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import type {AttributionRect, LayoutShiftAttribution} from '../core/performance-types' import {addBoundedAttribution, ATTRIBUTION_SOURCE_LIMIT, getElementSelector} from './attribution' import type {MetricCollector} from './types' @@ -83,13 +84,25 @@ export class LayoutShiftCollector implements MetricCollector { #epochMs = 0 #observer: PerformanceObserver | null = null + #overheadTelemetry: OverheadTelemetry | undefined + + constructor(overheadTelemetry?: OverheadTelemetry) { + this.#overheadTelemetry = overheadTelemetry + } start(): void { this.#epochMs = performance.now() try { this.#observer = new PerformanceObserver(list => { - for (const entry of list.getEntries()) { - this.#processEntry(entry as LayoutShift) + const processEntries = () => { + for (const entry of list.getEntries()) { + this.#processEntry(entry as LayoutShift) + } + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('layout-shift.entries', processEntries) + } else { + processEntries() } }) this.#observer.observe({type: 'layout-shift', buffered: true}) 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 e768686..ba18815 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,6 +8,7 @@ * @see https://w3c.github.io/long-animation-frames/ */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' 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' @@ -79,6 +80,8 @@ export interface LongAnimationFrameMetrics { p95LoafDuration: number /** Count of LoAFs with script attribution */ loafsWithScripts: number + /** Count of LoAFs with native forced style/layout attribution */ + loafsWithForcedStyleAndLayout: number /** Most recent LoAF details for debugging */ lastLoaf: LoAFDetails | null /** Details about the worst (longest) LoAF */ @@ -107,14 +110,17 @@ export class LongAnimationFrameCollector implements MetricCollector { - for (const entry of list.getEntries()) { - this.#processEntry(entry as PerformanceLongAnimationFrameTiming) + const processEntries = () => { + for (const entry of list.getEntries()) { + this.#processEntry(entry as PerformanceLongAnimationFrameTiming) + } + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('loaf.entries', processEntries) + } else { + processEntries() } }) this.#observer.observe({type: 'long-animation-frame', buffered: true}) @@ -180,6 +193,9 @@ export class LongAnimationFrameCollector implements MetricCollector total + (script.forcedStyleAndLayoutDuration ?? 0), 0, ) + if (forcedStyleAndLayoutDuration > 0) { + this.#loafsWithForcedStyleAndLayout++ + } // Build frame details const frameDetails = { @@ -215,6 +231,7 @@ export class LongAnimationFrameCollector implements MetricCollector { #longestTask = 0 #totalBlockingTime = 0 #observer: PerformanceObserver | null = null + #overheadTelemetry: OverheadTelemetry | undefined + + constructor(overheadTelemetry?: OverheadTelemetry) { + this.#overheadTelemetry = overheadTelemetry + } start(): void { try { this.#observer = new PerformanceObserver(list => { - for (const entry of list.getEntries()) { - this.#longTasks++ - if (entry.duration > this.#longestTask) { - this.#longestTask = entry.duration + const processEntries = () => { + for (const entry of list.getEntries()) { + this.#longTasks++ + if (entry.duration > this.#longestTask) { + this.#longestTask = entry.duration + } + // TBT = sum of (duration - 50ms) for all long tasks + this.#totalBlockingTime += Math.max(0, entry.duration - 50) } - // TBT = sum of (duration - 50ms) for all long tasks - this.#totalBlockingTime += Math.max(0, entry.duration - 50) + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('main-thread.entries', processEntries) + } else { + processEntries() } }) this.#observer.observe({type: 'longtask'}) diff --git a/packages/storybook-addon-performance-panel/collectors/paint-collector.ts b/packages/storybook-addon-performance-panel/collectors/paint-collector.ts index d90cb84..5747f7f 100644 --- a/packages/storybook-addon-performance-panel/collectors/paint-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/paint-collector.ts @@ -3,6 +3,7 @@ * @module collectors/PaintCollector */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import type {ScriptResourceAttribution} from '../core/performance-types' import { ATTRIBUTION_ENTRY_LIMIT, @@ -24,11 +25,13 @@ export interface PaintMetrics { * Schedule work during browser idle periods. * Falls back to setTimeout for environments without requestIdleCallback (e.g. Safari). */ -function scheduleIdle(callback: () => void, options?: IdleRequestOptions): number { +function scheduleIdle(callback: IdleRequestCallback, options?: IdleRequestOptions): number { if (typeof requestIdleCallback === 'function') { return requestIdleCallback(callback, options) } - return setTimeout(callback, 0) as unknown as number + return setTimeout(() => { + callback({didTimeout: true, timeRemaining: () => 0}) + }, 0) as unknown as number } function cancelIdle(id: number): void { @@ -39,6 +42,8 @@ function cancelIdle(id: number): void { } } +const LAYER_SCAN_CHUNK_SIZE = 50 + /** * Collects initial paint milestones, script resource loading time, and layer-promotion candidates. * @@ -58,8 +63,10 @@ export class PaintCollector implements MetricCollector { #layerElements = new Set() /** Elements whose layer status needs rechecking (attribute changed) */ #pendingChecks = new Set() - /** Subtree roots that were added and need scanning */ - #pendingSubtrees: Element[] = [] + /** Breadth-first queue of story elements awaiting computed-style checks */ + #pendingScanElements: Element[] = [] + #queuedScanElements = new Set() + #scanCursor = 0 /** Whether removed nodes need cleanup from #layerElements */ #hasRemovals = false /** Pending idle callback ID */ @@ -68,16 +75,43 @@ export class PaintCollector implements MetricCollector { #paintObserver: PerformanceObserver | null = null #resourceObserver: PerformanceObserver | null = null #layerObserver: MutationObserver | null = null + #container: HTMLElement | null = null + #running = false + #overheadTelemetry: OverheadTelemetry | undefined /** Entries before this timestamp belong to an earlier story or reset. */ #epochMs = 0 + constructor(overheadTelemetry?: OverheadTelemetry) { + this.#overheadTelemetry = overheadTelemetry + } + + setContainer(container: HTMLElement | null): void { + if (this.#container === container) return + this.#container = container + if (!this.#running) return + + this.#stopLayerTracking() + this.#layerElements.clear() + this.#compositorLayers = null + this.#startLayerTracking() + } + start(): void { + if (this.#running) return + this.#running = true this.#epochMs = performance.now() // Paint observer try { this.#paintObserver = new PerformanceObserver(list => { - this.#paintCount += list.getEntries().filter(entry => entry.startTime >= this.#epochMs).length + const processEntries = () => { + this.#paintCount += list.getEntries().filter(entry => entry.startTime >= this.#epochMs).length + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('paint.entries', processEntries) + } else { + processEntries() + } }) this.#paintObserver.observe({type: 'paint', buffered: true}) } catch { @@ -87,31 +121,38 @@ export class PaintCollector implements MetricCollector { // Resource observer for script loading duration try { this.#resourceObserver = new PerformanceObserver(list => { - for (const entry of list.getEntries()) { - if (entry.startTime < this.#epochMs) continue - if (entry.entryType === 'resource') { - const resourceEntry = entry as PerformanceResourceTiming - if (resourceEntry.initiatorType === 'script') { - 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) + const processEntries = () => { + for (const entry of list.getEntries()) { + if (entry.startTime < this.#epochMs) continue + if (entry.entryType === 'resource') { + const resourceEntry = entry as PerformanceResourceTiming + if (resourceEntry.initiatorType === 'script') { + 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) + } } } } } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('paint.resources', processEntries) + } else { + processEntries() + } }) this.#resourceObserver.observe({type: 'resource', buffered: true}) } catch { @@ -123,6 +164,7 @@ export class PaintCollector implements MetricCollector { } stop(): void { + this.#running = false this.#paintObserver?.disconnect() this.#resourceObserver?.disconnect() this.#paintObserver = null @@ -138,7 +180,7 @@ export class PaintCollector implements MetricCollector { this.#compositorLayers = null this.#layerElements.clear() this.#pendingChecks.clear() - this.#pendingSubtrees = [] + this.#clearPendingScanElements() this.#hasRemovals = false this.#epochMs = performance.now() this.#cancelPendingScan() @@ -149,27 +191,36 @@ export class PaintCollector implements MetricCollector { } #startLayerTracking(): void { + if (!this.#container) return this.#scheduleFullScan() this.#layerObserver = new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.type === 'attributes') { - this.#pendingChecks.add(mutation.target as Element) - } else if (mutation.type === 'childList') { - if (mutation.removedNodes.length > 0) { - this.#hasRemovals = true - } - for (const node of mutation.addedNodes) { - if (node instanceof Element) { - this.#pendingSubtrees.push(node) + const processMutations = () => { + this.#overheadTelemetry?.recordScan('paint.mutation-records', mutations.length) + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + this.#pendingChecks.add(mutation.target as Element) + } else if (mutation.type === 'childList') { + if (mutation.removedNodes.length > 0) { + this.#hasRemovals = true + } + for (const node of mutation.addedNodes) { + if (node instanceof Element) { + this.#enqueueScanElement(node) + } } } } + this.#scheduleIncrementalScan() + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('paint.mutations', processMutations) + } else { + processMutations() } - this.#scheduleIncrementalScan() }) - this.#layerObserver.observe(document.documentElement, { + this.#layerObserver.observe(this.#container, { attributes: true, attributeFilter: ['style', 'class'], childList: true, @@ -182,7 +233,7 @@ export class PaintCollector implements MetricCollector { this.#layerObserver = null this.#cancelPendingScan() this.#pendingChecks.clear() - this.#pendingSubtrees = [] + this.#clearPendingScanElements() this.#hasRemovals = false } @@ -190,77 +241,110 @@ export class PaintCollector implements MetricCollector { if (this.#idleCallbackId !== null) { cancelIdle(this.#idleCallbackId) this.#idleCallbackId = null + this.#overheadTelemetry?.setPendingWork('paint.idle-scan', 0) } } #scheduleFullScan(): void { this.#cancelPendingScan() - this.#idleCallbackId = scheduleIdle( - () => { - this.#idleCallbackId = null - this.#fullScan() - }, - {timeout: 1000}, - ) + this.#layerElements.clear() + this.#pendingChecks.clear() + this.#clearPendingScanElements() + this.#hasRemovals = false + this.#compositorLayers = null + + if (this.#container) { + for (const child of this.#container.children) { + this.#enqueueScanElement(child) + } + } + this.#scheduleIncrementalScan({timeout: 1000}) } - #scheduleIncrementalScan(): void { + #scheduleIncrementalScan(options?: IdleRequestOptions): void { if (this.#idleCallbackId !== null) return this.#idleCallbackId = scheduleIdle(() => { this.#idleCallbackId = null - this.#processIncrementalChanges() - }) + this.#overheadTelemetry?.setPendingWork('paint.idle-scan', 0) + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('paint.layer-scan', () => { + this.#processScanChunk() + }) + } else { + this.#processScanChunk() + } + }, options) + this.#overheadTelemetry?.setPendingWork('paint.idle-scan', 1) } - #fullScan(): void { - this.#layerElements.clear() - this.#pendingChecks.clear() - this.#pendingSubtrees = [] - this.#hasRemovals = false + #enqueueScanElement(element: Element): void { + if (this.#queuedScanElements.has(element)) return + this.#queuedScanElements.add(element) + this.#pendingScanElements.push(element) + } - for (const el of document.querySelectorAll('*')) { - if (this.#hasLayerPromotion(el)) { - this.#layerElements.add(el) - } - } - this.#compositorLayers = this.#layerElements.size + #clearPendingScanElements(): void { + this.#pendingScanElements = [] + this.#queuedScanElements.clear() + this.#scanCursor = 0 } - #processIncrementalChanges(): void { + #processScanChunk(): void { + const container = this.#container + if (!container) { + this.#clearPendingScanElements() + this.#pendingChecks.clear() + return + } + // Clean up disconnected elements from removals if (this.#hasRemovals) { for (const el of this.#layerElements) { - if (!el.isConnected) this.#layerElements.delete(el) + if (!container.contains(el)) this.#layerElements.delete(el) } this.#hasRemovals = false } - // Check added subtrees - for (const root of this.#pendingSubtrees) { - if (!root.isConnected) continue - if (this.#hasLayerPromotion(root)) { - this.#layerElements.add(root) - } - for (const el of root.querySelectorAll('*')) { - if (this.#hasLayerPromotion(el)) { - this.#layerElements.add(el) + let processed = 0 + while (processed < LAYER_SCAN_CHUNK_SIZE && this.#scanCursor < this.#pendingScanElements.length) { + const element = this.#pendingScanElements[this.#scanCursor++] + processed++ + if (element) this.#queuedScanElements.delete(element) + if (element && container.contains(element)) { + if (this.#hasLayerPromotion(element)) { + this.#layerElements.add(element) + } else { + this.#layerElements.delete(element) + } + for (const child of element.children) { + this.#enqueueScanElement(child) } } } - this.#pendingSubtrees = [] // Re-check elements with changed attributes - for (const el of this.#pendingChecks) { - if (!el.isConnected) { - this.#layerElements.delete(el) - } else if (this.#hasLayerPromotion(el)) { - this.#layerElements.add(el) + while (processed < LAYER_SCAN_CHUNK_SIZE && this.#pendingChecks.size > 0) { + const element = this.#pendingChecks.values().next().value + if (!element) break + this.#pendingChecks.delete(element) + processed++ + if (element === container || !container.contains(element)) { + this.#layerElements.delete(element) + } else if (this.#hasLayerPromotion(element)) { + this.#layerElements.add(element) } else { - this.#layerElements.delete(el) + this.#layerElements.delete(element) } } - this.#pendingChecks.clear() + this.#overheadTelemetry?.recordScan('paint.layer-elements', processed) + + if (this.#scanCursor < this.#pendingScanElements.length || this.#pendingChecks.size > 0) { + this.#scheduleIncrementalScan() + return + } + + this.#clearPendingScanElements() this.#compositorLayers = this.#layerElements.size } diff --git a/packages/storybook-addon-performance-panel/collectors/react-profiler-collector.ts b/packages/storybook-addon-performance-panel/collectors/react-profiler-collector.ts index 550f6d0..fb5cab5 100644 --- a/packages/storybook-addon-performance-panel/collectors/react-profiler-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/react-profiler-collector.ts @@ -3,6 +3,7 @@ * @module collectors/ReactProfilerCollector */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import type {ReactMetrics, RenderInfo} from '../core/performance-types' import type {MetricCollector} from './types' import {addToWindow} from './utils' @@ -63,6 +64,11 @@ export class ReactProfilerCollector implements MetricCollector { // Callback for external listeners (e.g., store updates) #onProfilerUpdate?: (storyId: string, profilerId: string, metrics: ReactMetrics) => void + #overheadTelemetry: OverheadTelemetry | undefined + + constructor(overheadTelemetry?: OverheadTelemetry) { + this.#overheadTelemetry = overheadTelemetry + } start(): void { // No setup needed - metrics come from reportRender calls @@ -135,6 +141,16 @@ export class ReactProfilerCollector implements MetricCollector { * @param info - Render information from React Profiler callback */ reportRender = (info: RenderInfo): void => { + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('react.profiler', () => { + this.#processRender(info) + }) + } else { + this.#processRender(info) + } + } + + #processRender(info: RenderInfo): void { // Calculate commit lag: time from render start to commit, minus actual render time // This represents time spent waiting (e.g., for other work, Suspense, etc.) const commitLag = Math.max(0, info.commitTime - info.startTime - info.actualDuration) diff --git a/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts b/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts index 9fa2654..6f0ee9d 100644 --- a/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts +++ b/packages/storybook-addon-performance-panel/collectors/style-mutation-collector.ts @@ -3,6 +3,7 @@ * @module collectors/StyleMutationCollector */ +import type {OverheadTelemetry} from '../core/overhead-telemetry' import {THRASHING_FRAME_THRESHOLD, THRASHING_STYLE_WRITE_WINDOW} from './constants' import type {MetricCollector} from './types' import {addToWindow} from './utils' @@ -36,52 +37,83 @@ export class StyleMutationCollector implements MetricCollector { #observer: MutationObserver | null = null #sampleInterval: ReturnType | null = null + #container: HTMLElement | null = null + #overheadTelemetry: OverheadTelemetry | undefined - /** Callback when layout becomes dirty (for reflow detection) */ - onLayoutDirty?: () => void + constructor(overheadTelemetry?: OverheadTelemetry) { + this.#overheadTelemetry = overheadTelemetry + } + + setContainer(container: HTMLElement | null): void { + if (this.#container === container) return + this.#container = container + this.#observeContainer() + } start(): void { // Single observer for both style and DOM mutations this.#observer = new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.type === 'attributes' && mutation.attributeName === 'style') { - this.#styleWrites++ - this.#styleWriteCount++ - this.#lastStyleWriteTime = performance.now() - this.onLayoutDirty?.() - - // Count CSS variable changes by diffing old vs new style - const target = mutation.target as HTMLElement - const newValue = target.getAttribute('style') ?? '' - const oldValue = mutation.oldValue ?? '' - const newVars = newValue.match(/--[\w-]+\s*:[^;]*/g) - const oldVars = oldValue.match(/--[\w-]+\s*:[^;]*/g) - if (newVars) { - const oldSet = new Set(oldVars?.map(s => s.trim())) - for (const v of newVars) { - if (!oldSet.has(v.trim())) this.#cssVarChanges++ + const processMutations = () => { + this.#overheadTelemetry?.recordScan('style.mutation-records', mutations.length) + for (const mutation of mutations) { + if (mutation.type === 'attributes' && mutation.attributeName === 'style') { + this.#styleWrites++ + this.#styleWriteCount++ + this.#lastStyleWriteTime = performance.now() + + // Count CSS variable changes by diffing old vs new style + const target = mutation.target as HTMLElement + const newValue = target.getAttribute('style') ?? '' + const oldValue = mutation.oldValue ?? '' + const newVars = newValue.match(/--[\w-]+\s*:[^;]*/g) + const oldVars = oldValue.match(/--[\w-]+\s*:[^;]*/g) + if (newVars) { + const oldSet = new Set(oldVars?.map(s => s.trim())) + for (const v of newVars) { + if (!oldSet.has(v.trim())) this.#cssVarChanges++ + } } + } else if (mutation.type === 'childList') { + this.#domMutationCount += mutation.addedNodes.length + mutation.removedNodes.length + } else if (mutation.type === 'attributes' && mutation.attributeName !== 'style') { + this.#domMutationCount++ } - } else if (mutation.type === 'childList') { - this.#domMutationCount += mutation.addedNodes.length + mutation.removedNodes.length - } else if (mutation.type === 'attributes' && mutation.attributeName !== 'style') { - this.#domMutationCount++ } } + + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('style.mutations', processMutations) + } else { + processMutations() + } }) - this.#observer.observe(document.body, { + this.#observeContainer() + + // Sample DOM mutations periodically + this.#sampleInterval = setInterval(() => { + const sampleMutations = () => { + addToWindow(this.#domMutationFrames, this.#domMutationCount, 30) + this.#domMutationCount = 0 + } + if (this.#overheadTelemetry) { + this.#overheadTelemetry.measureCallback('style.sample', sampleMutations) + } else { + sampleMutations() + } + }, DOM_MUTATION_SAMPLE_INTERVAL_MS) + } + + #observeContainer(): void { + this.#observer?.disconnect() + if (!this.#observer || !this.#container) return + + this.#observer.observe(this.#container, { childList: true, attributes: true, attributeOldValue: true, subtree: true, attributeFilter: ['style', 'class', 'id', 'data-state', 'aria-expanded', 'aria-hidden', 'hidden', 'disabled'], }) - - // Sample DOM mutations periodically - this.#sampleInterval = setInterval(() => { - addToWindow(this.#domMutationFrames, this.#domMutationCount, 30) - this.#domMutationCount = 0 - }, DOM_MUTATION_SAMPLE_INTERVAL_MS) } stop(): void { diff --git a/packages/storybook-addon-performance-panel/core/__tests__/overhead-telemetry.browser.test.ts b/packages/storybook-addon-performance-panel/core/__tests__/overhead-telemetry.browser.test.ts new file mode 100644 index 0000000..b495e66 --- /dev/null +++ b/packages/storybook-addon-performance-panel/core/__tests__/overhead-telemetry.browser.test.ts @@ -0,0 +1,36 @@ +import {afterEach, describe, expect, it, vi} from 'vitest' + +import {OverheadTelemetry} from '../overhead-telemetry' + +describe('OverheadTelemetry', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('records callback timing and keeps thrown errors observable', () => { + vi.spyOn(performance, 'now').mockReturnValueOnce(10).mockReturnValueOnce(12) + const telemetry = new OverheadTelemetry() + + expect(telemetry.measureCallback('style.mutations', () => 42)).toBe(42) + expect(telemetry.snapshot().callbacks['style.mutations']).toEqual({ + count: 1, + totalDurationMs: 2, + maxDurationMs: 2, + }) + }) + + it('records serialized UTF-8 bytes, scans, and pending-work peaks', () => { + const telemetry = new OverheadTelemetry() + + telemetry.measureSerialization({label: 'metric'}) + telemetry.recordScan('paint.layers', 12) + telemetry.setPendingWork('input.raf', 1) + telemetry.setPendingWork('input.raf', 0) + + const snapshot = telemetry.snapshot() + expect(snapshot.serialization.count).toBe(1) + expect(snapshot.serialization.bytes).toBe(new TextEncoder().encode('{"label":"metric"}').byteLength) + expect(snapshot.scans['paint.layers']).toBe(12) + expect(snapshot.pendingWork['input.raf']).toEqual({current: 0, peak: 1}) + }) +}) diff --git a/packages/storybook-addon-performance-panel/core/overhead-telemetry.ts b/packages/storybook-addon-performance-panel/core/overhead-telemetry.ts new file mode 100644 index 0000000..51c424d --- /dev/null +++ b/packages/storybook-addon-performance-panel/core/overhead-telemetry.ts @@ -0,0 +1,96 @@ +export interface OverheadTimingStats { + count: number + totalDurationMs: number + maxDurationMs: number +} + +export interface OverheadPendingWorkStats { + current: number + peak: number +} + +export interface OverheadTelemetrySnapshot { + callbacks: Record + computeMetrics: OverheadTimingStats + serialization: OverheadTimingStats & {bytes: number} + scans: Record + pendingWork: Record +} + +function createTimingStats(): OverheadTimingStats { + return {count: 0, totalDurationMs: 0, maxDurationMs: 0} +} + +function copyTimingStats(stats: OverheadTimingStats): OverheadTimingStats { + return {...stats} +} + +export class OverheadTelemetry { + #callbacks = new Map() + #computeMetrics = createTimingStats() + #serialization = {...createTimingStats(), bytes: 0} + #scans = new Map() + #pendingWork = new Map() + + measureCallback(name: string, callback: () => T): T { + const startTime = performance.now() + try { + return callback() + } finally { + const stats = this.#callbacks.get(name) ?? createTimingStats() + this.#recordTiming(stats, performance.now() - startTime) + this.#callbacks.set(name, stats) + } + } + + recordComputeMetrics(durationMs: number): void { + this.#recordTiming(this.#computeMetrics, durationMs) + } + + measureSerialization(payload: unknown): void { + const startTime = performance.now() + try { + const serialized = JSON.stringify(payload) + const durationMs = performance.now() - startTime + this.#recordTiming(this.#serialization, durationMs) + this.#serialization.bytes += new TextEncoder().encode(serialized).byteLength + } catch { + // Telemetry must never affect channel delivery. + } + } + + recordScan(name: string, count = 1): void { + this.#scans.set(name, (this.#scans.get(name) ?? 0) + count) + } + + setPendingWork(name: string, current: number): void { + const stats = this.#pendingWork.get(name) ?? {current: 0, peak: 0} + stats.current = current + stats.peak = Math.max(stats.peak, current) + this.#pendingWork.set(name, stats) + } + + reset(): void { + this.#callbacks.clear() + this.#computeMetrics = createTimingStats() + this.#serialization = {...createTimingStats(), bytes: 0} + this.#scans.clear() + this.#pendingWork.clear() + } + + snapshot(): OverheadTelemetrySnapshot { + return { + callbacks: Object.fromEntries([...this.#callbacks].map(([name, stats]) => [name, copyTimingStats(stats)])), + computeMetrics: copyTimingStats(this.#computeMetrics), + serialization: {...this.#serialization}, + scans: Object.fromEntries(this.#scans), + pendingWork: Object.fromEntries([...this.#pendingWork].map(([name, stats]) => [name, {...stats}])), + } + } + + #recordTiming(stats: OverheadTimingStats, durationMs: number): void { + stats.count++ + stats.totalDurationMs += durationMs + stats.maxDurationMs = Math.max(stats.maxDurationMs, durationMs) + } +} diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index 3487fd9..31350ef 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -513,6 +513,8 @@ export interface PerformanceMetrics { p95LoafDuration: number /** Count of LoAFs with script attribution */ loafsWithScripts: number + /** Count of LoAFs with native forced style/layout attribution */ + loafsWithForcedStyleAndLayout: number /** Details about the most recent LoAF */ lastLoaf: LoAFDetails | null /** Details about the worst (longest) LoAF */ @@ -533,7 +535,7 @@ export interface PerformanceMetrics { currentSessionCLS: number /** Recent layout shifts with bounded source selectors and geometry */ layoutShiftAttribution: LayoutShiftAttribution[] - /** Synchronous reads that forced browser reflow */ + /** @deprecated Use loafsWithForcedStyleAndLayout. This compatibility field mirrors that native LoAF count. */ forcedReflowCount: number /** Average DOM mutations normalized to a one-second rate */ domMutationsPerSecond: number @@ -682,6 +684,7 @@ export const PERFORMANCE_METRIC_METADATA = { avgLoafDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, p95LoafDuration: {provenance: 'derived', quality: 'high', unit: 'milliseconds'}, loafsWithScripts: {provenance: 'derived', quality: 'high', unit: 'count'}, + loafsWithForcedStyleAndLayout: {provenance: 'derived', quality: 'high', unit: 'count'}, lastLoaf: {provenance: 'native', quality: 'high', unit: 'structured'}, worstLoaf: {provenance: 'derived', quality: 'high', unit: 'structured'}, styleWrites: {provenance: 'derived', quality: 'high', unit: 'count'}, @@ -690,7 +693,7 @@ export const PERFORMANCE_METRIC_METADATA = { 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'}, + forcedReflowCount: {provenance: 'derived', quality: 'high', unit: 'count'}, domMutationsPerSecond: {provenance: 'derived', quality: 'medium', unit: 'per-second'}, domMutationsPerFrame: {provenance: 'derived', quality: 'medium', unit: 'count'}, cssVarChanges: {provenance: 'derived', quality: 'medium', unit: 'count'}, @@ -771,6 +774,7 @@ export const DEFAULT_METRICS: PerformanceMetrics = { avgLoafDuration: 0, p95LoafDuration: 0, loafsWithScripts: 0, + loafsWithForcedStyleAndLayout: 0, lastLoaf: null, worstLoaf: null, // Layout & Style diff --git a/packages/storybook-addon-performance-panel/core/preview-core.ts b/packages/storybook-addon-performance-panel/core/preview-core.ts index e62014b..a896856 100644 --- a/packages/storybook-addon-performance-panel/core/preview-core.ts +++ b/packages/storybook-addon-performance-panel/core/preview-core.ts @@ -21,6 +21,7 @@ import {addons} from 'storybook/preview-api' import {CollectorManager} from '../collectors/collector-manager' +import type {OverheadTelemetry} from './overhead-telemetry' import {performanceStore} from './performance-store' import {PERF_EVENTS} from './performance-types' @@ -113,13 +114,18 @@ export class PerformanceMonitorCore { private containerCleanup: (() => void) | null = null private channelCleanups: (() => void)[] = [] private panelVisible = false + private overheadTelemetry: OverheadTelemetry | undefined - constructor(storyId: string) { + constructor(storyId: string, {overheadTelemetry}: {overheadTelemetry?: OverheadTelemetry} = {}) { this.storyId = storyId + this.overheadTelemetry = overheadTelemetry this.manager = new CollectorManager({ + overheadTelemetry, onProfilerUpdate: (profilerStoryId, id, metrics) => { performanceStore.updateProfiler(id, metrics) - addons.getChannel().emit(PERF_EVENTS.PROFILER_UPDATE, {id, metrics, storyId: profilerStoryId}) + const payload = {id, metrics, storyId: profilerStoryId} + this.overheadTelemetry?.measureSerialization(payload) + addons.getChannel().emit(PERF_EVENTS.PROFILER_UPDATE, payload) }, }) } @@ -130,9 +136,17 @@ export class PerformanceMonitorCore { this.panelVisible = false const emitMetrics = () => { - const computed = this.manager.computeMetrics() - channel.emit(PERF_EVENTS.METRICS_UPDATE, computed) - performanceStore.setGlobalMetrics(computed) + const emit = () => { + const computed = this.manager.computeMetrics() + this.overheadTelemetry?.measureSerialization(computed) + channel.emit(PERF_EVENTS.METRICS_UPDATE, computed) + performanceStore.setGlobalMetrics(computed) + } + if (this.overheadTelemetry) { + this.overheadTelemetry.measureCallback('core.metrics-update', emit) + } else { + emit() + } } const handleRequestMetrics = () => { @@ -140,7 +154,9 @@ export class PerformanceMonitorCore { for (const id of this.manager.getProfilerIds()) { const metrics = this.manager.getProfilerMetrics(id) if (metrics) { - channel.emit(PERF_EVENTS.PROFILER_UPDATE, {id, metrics, storyId: this.storyId}) + const payload = {id, metrics, storyId: this.storyId} + this.overheadTelemetry?.measureSerialization(payload) + channel.emit(PERF_EVENTS.PROFILER_UPDATE, payload) } } } @@ -213,7 +229,13 @@ export class PerformanceMonitorCore { #startLiveUpdates(emitMetrics: () => void): void { this.metricsIntervalId ??= setInterval(emitMetrics, UPDATE_INTERVAL_MS) this.sparklineIntervalId ??= setInterval(() => { - this.manager.updateSparklineData() + if (this.overheadTelemetry) { + this.overheadTelemetry.measureCallback('core.sparkline-update', () => { + this.manager.updateSparklineData() + }) + } else { + this.manager.updateSparklineData() + } }, SPARKLINE_SAMPLE_INTERVAL_MS) } diff --git a/packages/storybook-addon-performance-panel/package.json b/packages/storybook-addon-performance-panel/package.json index 27e4800..b516fb2 100644 --- a/packages/storybook-addon-performance-panel/package.json +++ b/packages/storybook-addon-performance-panel/package.json @@ -73,6 +73,7 @@ ], "scripts": { "benchmark": "vitest bench --config vitest.benchmark.config.ts --outputJson benchmark-results.json", + "benchmark:compare": "node __benchmarks__/compare-results.ts benchmark-results.json", "build": "tsdown", "build:watch": "tsdown --watch", "test": "vitest run" diff --git a/packages/storybook-addon-performance-panel/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx index de0c525..55a454d 100644 --- a/packages/storybook-addon-performance-panel/performance-panel.tsx +++ b/packages/storybook-addon-performance-panel/performance-panel.tsx @@ -1020,7 +1020,7 @@ const ElementTimingSection = React.memo(function ElementTimingSection({ * * Displays: * - CLS: Cumulative Layout Shift (Core Web Vital) - * - Forced Reflows: Synchronous layout caused by read-after-write + * - Forced Style / Layout: LoAFs with native forced style/layout attribution * - Style Writes: Inline style mutations * - Jitter: Input latency spikes * @@ -1032,7 +1032,8 @@ type LayoutAndInternalsSectionProps = Pick< | 'layoutShiftCount' | 'currentSessionCLS' | 'layoutShiftAttribution' - | 'forcedReflowCount' + | 'loafSupported' + | 'loafsWithForcedStyleAndLayout' | 'styleWrites' | 'cssVarChanges' | 'inputJitter' @@ -1045,14 +1046,15 @@ const LayoutAndInternalsSection = React.memo(function LayoutAndInternalsSection( layoutShiftCount, currentSessionCLS, layoutShiftAttribution, - forcedReflowCount, + loafSupported, + loafsWithForcedStyleAndLayout, 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 reflowStatus = getStatus(loafsWithForcedStyleAndLayout, 0, THRESHOLDS.FORCED_REFLOW_WARNING) const jitterStatus = getZeroStatus(inputJitter) const latestShift = layoutShiftAttribution.at(-1) const latestShiftSource = latestShift?.sources[0] @@ -1104,13 +1106,17 @@ const LayoutAndInternalsSection = React.memo(function LayoutAndInternalsSection( )} - - {forcedReflowCount === 0 ? '✨ ' : 'πŸ’₯ '} - {forcedReflowCount} - + {loafSupported ? ( + + {loafsWithForcedStyleAndLayout === 0 ? '✨ ' : 'πŸ’₯ '} + {loafsWithForcedStyleAndLayout} + + ) : ( + Chrome/Edge only + )}