Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reduce-collector-overhead.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 16 additions & 6 deletions packages/storybook-addon-performance-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <baseline.json> <candidate.json>')
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

async function readBenchmarks(filePath: string): Promise<Map<string, BenchmarkSummary>> {
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<string, BenchmarkSummary>()
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)} |`,
)
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -64,6 +65,61 @@ function setPanelVisibility(visible: boolean): void {
addons.getChannel().emit(PERF_EVENTS.PANEL_VISIBILITY, visible)
}

function nextAnimationFrame(): Promise<void> {
return new Promise(resolve => {
requestAnimationFrame(() => {
resolve()
})
})
}

function nextIdlePeriod(): Promise<void> {
return new Promise(resolve => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => {
resolve()
})
} else {
setTimeout(resolve, 0)
}
})
}

async function runTelemetryProbe(): Promise<void> {
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')
Expand Down Expand Up @@ -217,3 +273,7 @@ describe('React commit workload', () => {
bench(state, workload.run, workload.options)
}
})

afterAll(async () => {
await runTelemetryProbe()
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────

Expand Down Expand Up @@ -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()

Expand Down
46 changes: 5 additions & 41 deletions packages/storybook-addon-performance-panel/collectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ✅
Expand Down Expand Up @@ -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+.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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)
})
})
Loading