diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46745c2..78391bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,14 @@ jobs: - run: npx playwright install --with-deps chromium firefox webkit - run: npx vitest run --project browser working-directory: packages/storybook-addon-performance-panel + - run: npm run benchmark + - uses: actions/upload-artifact@v7 + if: always() + with: + name: addon-overhead-benchmark + path: packages/storybook-addon-performance-panel/benchmark-results.json + if-no-files-found: error + retention-days: 14 ci: name: CI diff --git a/.gitignore b/.gitignore index cded7cd..2e7d7e4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,7 @@ storybook-static/ .tanstack/ # Generated CSS module type declarations -*.module.css.d.ts \ No newline at end of file +*.module.css.d.ts + +# Local benchmark output +benchmark-results.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8846e40..07c5661 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,7 @@ This is an npm workspaces monorepo with multiple packages: |---------|-------------| | `npm run build` | Build the addon | | `npm run build:watch` | Build the addon in watch mode | +| `npm run benchmark` | Measure addon overhead in headless Chromium | | `npm test` | Run tests | | `npm run lint` | Lint with ESLint + Prettier | | `npm run tsc` | Type-check the addon | @@ -35,6 +36,12 @@ This is an npm workspaces monorepo with multiple packages: | `npm run docs:html` | Build the addon and start the HTML docs storybook | | `npm run docs:html:build` | Build the HTML docs for production | +### Overhead benchmarks + +Run `npm run benchmark` to measure the same lifecycle, raw DOM, and React workloads with the addon disabled, with the panel hidden, and with the panel visible. The command writes machine-readable results to `packages/storybook-addon-performance-panel/benchmark-results.json` for later comparison. + +Pass a previous result to `npm run benchmark -- --compare path/to/benchmark-results.json` to include relative results in the report. CI uploads each result as the `addon-overhead-benchmark` artifact. Benchmark results are diagnostic data, not pass/fail performance budgets. Run comparisons on the same machine and browser environment to reduce variance. + #### Portless dev URLs When using `npm run dev`, each storybook is served at a stable `.localhost` URL via [portless](https://github.com/nicolo-ribaudo/portless): diff --git a/package-lock.json b/package-lock.json index f8460e2..37aa1ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15212,11 +15212,11 @@ "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@vitejs/plugin-react": "^6.0.4", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "4.1.10", "esbuild": "0.28.1", "publint": "^0.3.22", "tsdown": "^0.22.14", - "vitest": "^4.1.2", + "vitest": "4.1.10", "vitest-browser-react": "^2.2.0" }, "peerDependencies": { diff --git a/package.json b/package.json index 2c44a1b..7f22990 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "npm run build -w @github-ui/storybook-addon-performance-panel", "build:watch": "npm run build:watch -w @github-ui/storybook-addon-performance-panel", + "benchmark": "npm run benchmark -w @github-ui/storybook-addon-performance-panel --", "dev": "npm run build && concurrently -k --kill-signal SIGINT --kill-timeout 3000 -n watch,react,html,site -c blue,green,magenta,cyan \"npm:build:watch\" \"npm:docs:dev\" \"npm:docs:html:dev\" \"npm:site:dev\"", "docs:dev": "npm run storybook -w @github-ui/examples-react", "docs:html:dev": "npm run storybook -w @github-ui/examples-html", diff --git a/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx b/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx new file mode 100644 index 0000000..47316e6 --- /dev/null +++ b/packages/storybook-addon-performance-panel/__benchmarks__/overhead.browser.bench.tsx @@ -0,0 +1,219 @@ +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 {PERF_EVENTS} from '../core/performance-types' +import {PerformanceMonitorCore} from '../core/preview-core' +import {PerformanceProvider, ProfiledComponent} from '../react/performance-decorator' + +vi.mock('storybook/preview-api', () => { + type Listener = (...args: unknown[]) => void + + const listeners = new Map>() + const channel = { + emit(event: string, ...args: unknown[]) { + for (const listener of listeners.get(event) ?? []) listener(...args) + }, + on(event: string, listener: Listener) { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + }, + off(event: string, listener: Listener) { + listeners.get(event)?.delete(listener) + }, + } + + return {addons: {getChannel: () => channel}} +}) + +type LifecycleState = 'addon disabled' | 'panel hidden' | 'panel visible' + +interface WorkloadHarness { + run: () => Promise + options: BenchOptions +} + +const LIFECYCLE_STATES: readonly LifecycleState[] = ['addon disabled', 'panel hidden', 'panel visible'] +const BASE_OPTIONS = { + iterations: 20, + time: 500, + warmupIterations: 5, + warmupTime: 100, +} satisfies BenchOptions +const DOM_ROW_COUNT = 60 +const REACT_ROW_COUNT = 60 + +// Let observer callbacks settle without the nested timer clamp affecting samples. +const pendingYields: (() => void)[] = [] +const yieldChannel = new MessageChannel() + +yieldChannel.port1.onmessage = () => { + pendingYields.shift()?.() +} + +function yieldToMainThread(): Promise { + return new Promise(resolve => { + pendingYields.push(resolve) + yieldChannel.port2.postMessage(undefined) + }) +} + +function setPanelVisibility(visible: boolean): void { + addons.getChannel().emit(PERF_EVENTS.PANEL_VISIBILITY, visible) +} + +function createLifecycleBenchmark(state: LifecycleState): () => void { + return () => { + const container = document.createElement('div') + document.body.appendChild(container) + + if (state !== 'addon disabled') { + const core = new PerformanceMonitorCore('benchmark-lifecycle') + core.start() + core.observeContainer(container) + if (state === 'panel visible') setPanelVisibility(true) + core.stop() + } + + container.remove() + } +} + +function createDomWorkload(state: LifecycleState): WorkloadHarness { + let container: HTMLDivElement | null = null + let core: PerformanceMonitorCore | null = null + let revision = 0 + + return { + async run() { + if (!container) throw new Error('DOM benchmark ran before setup') + + revision += 1 + const fragment = document.createDocumentFragment() + + for (let index = 0; index < DOM_ROW_COUNT; index += 1) { + const row = document.createElement('div') + row.className = 'benchmark-row' + row.style.transform = `translateX(${String((revision + index) % 4)}px)` + row.textContent = `Row ${String(index)}: revision ${String(revision)}` + fragment.appendChild(row) + } + + container.replaceChildren(fragment) + container.style.paddingLeft = `${String(revision % 3)}px` + void container.offsetHeight + await yieldToMainThread() + }, + options: { + ...BASE_OPTIONS, + setup() { + revision = 0 + container = document.createElement('div') + container.dataset.benchmarkRoot = 'raw-dom' + document.body.appendChild(container) + + if (state !== 'addon disabled') { + core = new PerformanceMonitorCore('benchmark-dom') + core.start() + core.observeContainer(container) + if (state === 'panel visible') setPanelVisibility(true) + } + }, + teardown() { + core?.stop() + core = null + container?.remove() + container = null + }, + }, + } +} + +function ReactWorkload({revision}: {revision: number}) { + return ( +
+ {Array.from({length: REACT_ROW_COUNT}, (_, index) => ( +
+ Row {index}: revision {revision} +
+ ))} +
+ ) +} + +function createReactWorkload(state: LifecycleState): WorkloadHarness { + let container: HTMLDivElement | null = null + let root: Root | null = null + let revision = 0 + + const render = () => { + if (!root) throw new Error('React benchmark ran before setup') + const currentRoot = root + const workload = + + flushSync(() => { + currentRoot.render( + state === 'addon disabled' ? ( + workload + ) : ( + + {workload} + + ), + ) + }) + } + + return { + async run() { + revision += 1 + render() + await yieldToMainThread() + }, + options: { + ...BASE_OPTIONS, + setup() { + revision = 0 + container = document.createElement('div') + container.dataset.benchmarkRoot = 'react' + document.body.appendChild(container) + root = createRoot(container) + render() + if (state === 'panel visible') setPanelVisibility(true) + }, + teardown() { + if (root) { + const currentRoot = root + flushSync(() => { + currentRoot.unmount() + }) + } + root = null + container?.remove() + container = null + }, + }, + } +} + +describe('preview lifecycle startup and teardown', () => { + for (const state of LIFECYCLE_STATES) { + bench(state, createLifecycleBenchmark(state), BASE_OPTIONS) + } +}) + +describe('raw DOM mutation workload', () => { + for (const state of LIFECYCLE_STATES) { + const workload = createDomWorkload(state) + bench(state, workload.run, workload.options) + } +}) + +describe('React commit workload', () => { + for (const state of LIFECYCLE_STATES) { + const workload = createReactWorkload(state) + bench(state, workload.run, workload.options) + } +}) diff --git a/packages/storybook-addon-performance-panel/package.json b/packages/storybook-addon-performance-panel/package.json index 029cc8a..27e4800 100644 --- a/packages/storybook-addon-performance-panel/package.json +++ b/packages/storybook-addon-performance-panel/package.json @@ -72,6 +72,7 @@ "**/*.md" ], "scripts": { + "benchmark": "vitest bench --config vitest.benchmark.config.ts --outputJson benchmark-results.json", "build": "tsdown", "build:watch": "tsdown --watch", "test": "vitest run" @@ -111,11 +112,11 @@ "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@vitejs/plugin-react": "^6.0.4", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "4.1.10", "esbuild": "0.28.1", "publint": "^0.3.22", "tsdown": "^0.22.14", - "vitest": "^4.1.2", + "vitest": "4.1.10", "vitest-browser-react": "^2.2.0" }, "storybook": { diff --git a/packages/storybook-addon-performance-panel/vitest.benchmark.config.ts b/packages/storybook-addon-performance-panel/vitest.benchmark.config.ts new file mode 100644 index 0000000..4e50a88 --- /dev/null +++ b/packages/storybook-addon-performance-panel/vitest.benchmark.config.ts @@ -0,0 +1,33 @@ +import react from '@vitejs/plugin-react' +import {playwright} from '@vitest/browser-playwright' +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + optimizeDeps: { + include: [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + 'storybook/preview-api', + ], + }, + plugins: [react()], + test: { + benchmark: { + include: ['__benchmarks__/**/*.browser.bench.{ts,tsx}'], + }, + browser: { + enabled: true, + provider: playwright(), + instances: [ + { + browser: 'chromium', + headless: true, + }, + ], + }, + fileParallelism: false, + }, +})