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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ storybook-static/
.tanstack/

# Generated CSS module type declarations
*.module.css.d.ts
*.module.css.d.ts

# Local benchmark output
benchmark-results.json
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Set<Listener>>()
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<Listener>()
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<void>
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<void> {
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 (
<section data-revision={revision}>
{Array.from({length: REACT_ROW_COUNT}, (_, index) => (
<article key={index} style={{transform: `translateX(${String((revision + index) % 4)}px)`}}>
Row {index}: revision {revision}
</article>
))}
</section>
)
}

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 = <ReactWorkload revision={revision} />

flushSync(() => {
currentRoot.render(
state === 'addon disabled' ? (
workload
) : (
<PerformanceProvider storyId="benchmark-react">
<ProfiledComponent id="React benchmark">{workload}</ProfiledComponent>
</PerformanceProvider>
),
)
})
}

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)
}
})
5 changes: 3 additions & 2 deletions packages/storybook-addon-performance-panel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
})
Loading