diff --git a/.changeset/visible-panel-updates.md b/.changeset/visible-panel-updates.md
new file mode 100644
index 0000000..c5d4ed5
--- /dev/null
+++ b/.changeset/visible-panel-updates.md
@@ -0,0 +1,5 @@
+---
+'@github-ui/storybook-addon-performance-panel': minor
+---
+
+Pause browser collectors, DOM observation, metric transport, and sparkline sampling while the performance panel is hidden.
\ 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 60b20f5..aac5f45 100644
--- a/packages/storybook-addon-performance-panel/README.md
+++ b/packages/storybook-addon-performance-panel/README.md
@@ -179,6 +179,12 @@ import '@github-ui/storybook-addon-performance-panel/preset'
The universal entry collects all browser-level metrics (frame timing, CLS, INP, etc.) but omits React Profiler metrics. The React Performance section is automatically hidden in the panel.
+## Collection Lifecycle
+
+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.
+
+Use the reset button to clear accumulated metrics, or set `parameters.performancePanel.disable` to `true` to disable the addon for a story.
+
## Collectors
The addon uses modular collector classes for metrics gathering. Each collector uses the most accurate available API for its metrics.
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 9e1f7fa..71260e0 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
@@ -86,19 +86,48 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => {
expect(mockChannel.on).toHaveBeenCalledWith(PERF_EVENTS.REQUEST_METRICS, expect.any(Function))
expect(mockChannel.on).toHaveBeenCalledWith(PERF_EVENTS.RESET, expect.any(Function))
+ expect(mockChannel.on).toHaveBeenCalledWith(PERF_EVENTS.PANEL_VISIBILITY, expect.any(Function))
expect(mockChannel.on).toHaveBeenCalledWith(PERF_EVENTS.INSPECT_ELEMENT, expect.any(Function))
+ expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.REQUEST_PANEL_VISIBILITY)
})
- it('emits metrics periodically', async () => {
+ it('does not emit metrics periodically while the panel is hidden', async () => {
withPerformanceMonitor(
vi.fn(() => ''),
makeCtx(),
)
+ mockChannel.emit.mockClear()
- // The core emits every 50ms; wait enough for at least one tick
- await new Promise(resolve => setTimeout(resolve, 150))
+ await new Promise(resolve => setTimeout(resolve, 300))
+ expect(mockChannel.emit).not.toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
+ expect(getActiveCore()?.manager.isRunning).toBe(false)
+ })
+
+ it('emits live metrics only while the panel is visible', async () => {
+ withPerformanceMonitor(
+ vi.fn(() => ''),
+ makeCtx(),
+ )
+
+ const visibilityCall = mockChannel.on.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY)
+ expect(visibilityCall).toBeDefined()
+ const handleVisibility = visibilityCall?.[1] as (visible: boolean) => void
+
+ mockChannel.emit.mockClear()
+ handleVisibility(true)
+ expect(getActiveCore()?.manager.isRunning).toBe(true)
+ expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
+
+ mockChannel.emit.mockClear()
+ await new Promise(resolve => setTimeout(resolve, 300))
expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
+
+ handleVisibility(false)
+ expect(getActiveCore()?.manager.isRunning).toBe(false)
+ mockChannel.emit.mockClear()
+ await new Promise(resolve => setTimeout(resolve, 300))
+ expect(mockChannel.emit).not.toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
})
it('reuses the same core for repeated renders of the same story', () => {
@@ -137,6 +166,32 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => {
expect(second?.storyId).toBe('story-b')
})
+ it('requests visibility again so an open panel resumes after a story change', () => {
+ withPerformanceMonitor(
+ vi.fn(() => ''),
+ makeCtx({id: 'story-a'}),
+ )
+ const firstVisibilityCall = mockChannel.on.mock.calls.find(
+ (call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY,
+ )
+ const handleFirstVisibility = firstVisibilityCall?.[1] as (visible: boolean) => void
+ handleFirstVisibility(true)
+
+ mockChannel.emit.mockClear()
+ withPerformanceMonitor(
+ vi.fn(() => ''),
+ makeCtx({id: 'story-b'}),
+ )
+
+ expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.REQUEST_PANEL_VISIBILITY)
+ const visibilityCalls = mockChannel.on.mock.calls.filter(
+ (call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY,
+ )
+ const handleCurrentVisibility = visibilityCalls.at(-1)?.[1] as (visible: boolean) => void
+ handleCurrentVisibility(true)
+ expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
+ })
+
it('clears active core when disabled via parameters', () => {
// First enable it
withPerformanceMonitor(
@@ -191,13 +246,15 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => {
)
})
- it('emitted metrics include expected browser-level fields (no React fields populated)', async () => {
+ it('requested metrics include expected browser-level fields (no React fields populated)', () => {
withPerformanceMonitor(
vi.fn(() => ''),
makeCtx(),
)
- await new Promise(resolve => setTimeout(resolve, 150))
+ const requestCall = mockChannel.on.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.REQUEST_METRICS)
+ const handleRequest = requestCall?.[1] as () => void
+ handleRequest()
const metricsCall = mockChannel.emit.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.METRICS_UPDATE)
expect(metricsCall).toBeDefined()
@@ -239,6 +296,7 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => {
expect(mockChannel.off).toHaveBeenCalledWith(PERF_EVENTS.REQUEST_METRICS, expect.any(Function))
expect(mockChannel.off).toHaveBeenCalledWith(PERF_EVENTS.RESET, expect.any(Function))
+ expect(mockChannel.off).toHaveBeenCalledWith(PERF_EVENTS.PANEL_VISIBILITY, expect.any(Function))
expect(mockChannel.off).toHaveBeenCalledWith(PERF_EVENTS.INSPECT_ELEMENT, expect.any(Function))
})
})
diff --git a/packages/storybook-addon-performance-panel/__tests__/performance-decorator.browser.test.tsx b/packages/storybook-addon-performance-panel/__tests__/performance-decorator.browser.test.tsx
index 11f3812..42abcf6 100644
--- a/packages/storybook-addon-performance-panel/__tests__/performance-decorator.browser.test.tsx
+++ b/packages/storybook-addon-performance-panel/__tests__/performance-decorator.browser.test.tsx
@@ -117,15 +117,33 @@ describe('performance-decorator', () => {
resetSpy.mockRestore()
})
- it('emits metrics periodically', async () => {
+ it('does not emit metrics periodically while the panel is hidden', async () => {
await render(
Test
,
)
+ mockChannel.emit.mockClear()
- // Wait for real time to pass for metrics emission (50ms interval)
- await new Promise(resolve => setTimeout(resolve, 150))
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ expect(mockChannel.emit).not.toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
+ })
+
+ it('emits metrics periodically while the panel is visible', async () => {
+ await render(
+
+ Test
+ ,
+ )
+ const visibilityCall = mockChannel.on.mock.calls
+ .filter((call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY)
+ .at(-1)
+ const handleVisibility = visibilityCall?.[1] as (visible: boolean) => void
+
+ handleVisibility(true)
+ mockChannel.emit.mockClear()
+ await new Promise(resolve => setTimeout(resolve, 300))
expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
})
@@ -170,7 +188,9 @@ describe('performance-decorator', () => {
,
)
- await new Promise(resolve => setTimeout(resolve, 150))
+ const requestCall = mockChannel.on.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.REQUEST_METRICS)
+ const requestMetrics = requestCall?.[1] as () => void
+ requestMetrics()
const emittedCall = mockChannel.emit.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.METRICS_UPDATE)
@@ -329,7 +349,7 @@ describe('performance-decorator', () => {
expect(mockChannel.on).toHaveBeenCalled() // Provider is active
})
- it('emits metrics for wrapped story', async () => {
+ it('responds to metric requests for a wrapped story', async () => {
const Story = () =>
Story
const context = {id: 'test-story', parameters: {}} as Parameters[1]
@@ -337,7 +357,9 @@ describe('performance-decorator', () => {
await render()
- await new Promise(resolve => setTimeout(resolve, 150))
+ const requestCall = mockChannel.on.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.REQUEST_METRICS)
+ const requestMetrics = requestCall?.[1] as () => void
+ requestMetrics()
expect(mockChannel.emit).toHaveBeenCalledWith(PERF_EVENTS.METRICS_UPDATE, expect.any(Object))
})
@@ -355,8 +377,15 @@ describe('performance-decorator', () => {
,
)
- // Wait for initial count and metrics emission
- await new Promise(resolve => setTimeout(resolve, 600))
+ const visibilityCall = mockChannel.on.mock.calls
+ .filter((call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY)
+ .at(-1)
+ const handleVisibility = visibilityCall?.[1] as (visible: boolean) => void
+ handleVisibility(true)
+
+ const requestCall = mockChannel.on.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.REQUEST_METRICS)
+ const requestMetrics = requestCall?.[1] as () => void
+ requestMetrics()
const emittedCall2 = mockChannel.emit.mock.calls.find((call: unknown[]) => call[0] === PERF_EVENTS.METRICS_UPDATE)
diff --git a/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx b/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx
new file mode 100644
index 0000000..4bb8452
--- /dev/null
+++ b/packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx
@@ -0,0 +1,73 @@
+import {convert, ThemeProvider, themes} from 'storybook/theming'
+import {beforeEach, describe, expect, it, vi} from 'vitest'
+import {render} from 'vitest-browser-react'
+
+import {PERF_EVENTS} from '../core/performance-types'
+import {PerformancePanel} from '../performance-panel'
+
+type ChannelEventMap = Record void>
+
+const channel = vi.hoisted(() => ({
+ emit: vi.fn(),
+ registrations: [] as {events: ChannelEventMap; deps?: unknown[]}[],
+}))
+
+vi.mock('storybook/manager-api', () => ({
+ useChannel: (events: ChannelEventMap, deps?: unknown[]) => {
+ channel.registrations.push({events, deps})
+ return channel.emit
+ },
+ useStorybookState: () => ({
+ previewInitialized: true,
+ refId: undefined,
+ storyId: undefined,
+ viewMode: 'story',
+ }),
+}))
+
+function renderPanel(active: boolean) {
+ return (
+
+
+
+ )
+}
+
+describe('PerformancePanel visibility', () => {
+ beforeEach(() => {
+ channel.emit.mockClear()
+ channel.registrations.length = 0
+ })
+
+ it('reports the latest visibility after AddonPanel freezes its inactive children', async () => {
+ const {rerender} = await render(renderPanel(true))
+
+ await expect
+ .poll(() =>
+ channel.emit.mock.calls.some(
+ ([eventName, visible]) => eventName === PERF_EVENTS.PANEL_VISIBILITY && visible === true,
+ ),
+ )
+ .toBe(true)
+
+ await rerender(renderPanel(false))
+
+ await expect
+ .poll(() =>
+ channel.emit.mock.calls.some(
+ ([eventName, visible]) => eventName === PERF_EVENTS.PANEL_VISIBILITY && visible === false,
+ ),
+ )
+ .toBe(true)
+
+ const currentRegistration = channel.registrations
+ .filter(({events}) => PERF_EVENTS.REQUEST_PANEL_VISIBILITY in events)
+ .at(-1)
+
+ expect(currentRegistration?.deps).toEqual([false])
+ channel.emit.mockClear()
+ currentRegistration?.events[PERF_EVENTS.REQUEST_PANEL_VISIBILITY]?.()
+
+ expect(channel.emit).toHaveBeenCalledWith(PERF_EVENTS.PANEL_VISIBILITY, false)
+ })
+})
diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts
index a528fdf..ae9ea7a 100644
--- a/packages/storybook-addon-performance-panel/core/performance-types.ts
+++ b/packages/storybook-addon-performance-panel/core/performance-types.ts
@@ -116,6 +116,10 @@ export const PERF_EVENTS = {
RESET: `${ADDON_ID}/reset`,
/** Panel → Decorator: Request immediate metrics update */
REQUEST_METRICS: `${ADDON_ID}/request-metrics`,
+ /** Panel → Decorator: Start or stop browser collection based on panel visibility */
+ PANEL_VISIBILITY: `${ADDON_ID}/panel-visibility`,
+ /** Decorator → Panel: Request the current panel visibility state */
+ REQUEST_PANEL_VISIBILITY: `${ADDON_ID}/request-panel-visibility`,
/** Panel → Decorator: Highlight/inspect an element by selector */
INSPECT_ELEMENT: `${ADDON_ID}/inspect-element`,
/** Panel → Decorator: Select a specific profiler for display */
diff --git a/packages/storybook-addon-performance-panel/core/preview-core.ts b/packages/storybook-addon-performance-panel/core/preview-core.ts
index ec17a04..e62014b 100644
--- a/packages/storybook-addon-performance-panel/core/preview-core.ts
+++ b/packages/storybook-addon-performance-panel/core/preview-core.ts
@@ -28,8 +28,8 @@ import {PERF_EVENTS} from './performance-types'
// Timing Constants
// ============================================================================
-/** How often to emit metrics to the panel (ms) */
-const UPDATE_INTERVAL_MS = 50
+/** How often to emit metrics while the panel is visible (ms) */
+const UPDATE_INTERVAL_MS = 250
/** How often to sample sparkline data points (ms) */
const SPARKLINE_SAMPLE_INTERVAL_MS = 200
@@ -109,8 +109,10 @@ export class PerformanceMonitorCore {
private metricsIntervalId: ReturnType | null = null
private sparklineIntervalId: ReturnType | null = null
+ private containerElement: HTMLElement | null = null
private containerCleanup: (() => void) | null = null
private channelCleanups: (() => void)[] = []
+ private panelVisible = false
constructor(storyId: string) {
this.storyId = storyId
@@ -122,17 +124,19 @@ export class PerformanceMonitorCore {
})
}
- /**
- * Start all collectors and begin emitting metrics.
- * Sets up channel event listeners and periodic intervals.
- */
+ /** Set up channel listeners. Browser collectors begin when the panel becomes visible. */
start(): void {
const channel = addons.getChannel()
+ this.panelVisible = false
- this.manager.start()
+ const emitMetrics = () => {
+ const computed = this.manager.computeMetrics()
+ channel.emit(PERF_EVENTS.METRICS_UPDATE, computed)
+ performanceStore.setGlobalMetrics(computed)
+ }
const handleRequestMetrics = () => {
- channel.emit(PERF_EVENTS.METRICS_UPDATE, this.manager.computeMetrics())
+ emitMetrics()
for (const id of this.manager.getProfilerIds()) {
const metrics = this.manager.getProfilerMetrics(id)
if (metrics) {
@@ -145,8 +149,23 @@ export class PerformanceMonitorCore {
this.reset()
}
+ const handlePanelVisibility = (visible: boolean) => {
+ this.panelVisible = visible
+ if (visible) {
+ this.manager.start()
+ this.#startContainerObservation()
+ emitMetrics()
+ this.#startLiveUpdates(emitMetrics)
+ } else {
+ this.#stopLiveUpdates()
+ this.#stopContainerObservation()
+ this.manager.stop()
+ }
+ }
+
channel.on(PERF_EVENTS.REQUEST_METRICS, handleRequestMetrics)
channel.on(PERF_EVENTS.RESET, handleReset)
+ channel.on(PERF_EVENTS.PANEL_VISIBILITY, handlePanelVisibility)
channel.on(PERF_EVENTS.INSPECT_ELEMENT, handleInspectElement)
this.channelCleanups = [
@@ -156,20 +175,15 @@ export class PerformanceMonitorCore {
() => {
channel.off(PERF_EVENTS.RESET, handleReset)
},
+ () => {
+ channel.off(PERF_EVENTS.PANEL_VISIBILITY, handlePanelVisibility)
+ },
() => {
channel.off(PERF_EVENTS.INSPECT_ELEMENT, handleInspectElement)
},
]
- this.metricsIntervalId = setInterval(() => {
- const computed = this.manager.computeMetrics()
- channel.emit(PERF_EVENTS.METRICS_UPDATE, computed)
- performanceStore.setGlobalMetrics(computed)
- }, UPDATE_INTERVAL_MS)
-
- this.sparklineIntervalId = setInterval(() => {
- this.manager.updateSparklineData()
- }, SPARKLINE_SAMPLE_INTERVAL_MS)
+ channel.emit(PERF_EVENTS.REQUEST_PANEL_VISIBILITY)
}
/**
@@ -177,25 +191,17 @@ export class PerformanceMonitorCore {
* Removes channel listeners and clears intervals.
*/
stop(): void {
+ this.panelVisible = false
+ this.#stopLiveUpdates()
+ this.#stopContainerObservation()
this.manager.stop()
- if (this.metricsIntervalId != null) {
- clearInterval(this.metricsIntervalId)
- this.metricsIntervalId = null
- }
-
- if (this.sparklineIntervalId != null) {
- clearInterval(this.sparklineIntervalId)
- this.sparklineIntervalId = null
- }
-
for (const cleanup of this.channelCleanups) {
cleanup()
}
this.channelCleanups = []
- this.containerCleanup?.()
- this.containerCleanup = null
+ this.containerElement = null
}
/** Reset all collector and stored metrics without changing lifecycle state. */
@@ -204,14 +210,50 @@ export class PerformanceMonitorCore {
performanceStore.resetAll()
}
+ #startLiveUpdates(emitMetrics: () => void): void {
+ this.metricsIntervalId ??= setInterval(emitMetrics, UPDATE_INTERVAL_MS)
+ this.sparklineIntervalId ??= setInterval(() => {
+ this.manager.updateSparklineData()
+ }, SPARKLINE_SAMPLE_INTERVAL_MS)
+ }
+
+ #stopLiveUpdates(): void {
+ if (this.metricsIntervalId !== null) {
+ clearInterval(this.metricsIntervalId)
+ this.metricsIntervalId = null
+ }
+ if (this.sparklineIntervalId !== null) {
+ clearInterval(this.sparklineIntervalId)
+ this.sparklineIntervalId = null
+ }
+ }
+
+ #startContainerObservation(): void {
+ if (!this.panelVisible || !this.containerElement || this.containerCleanup) return
+ this.containerCleanup = this.manager.observeContainer(this.containerElement)
+ }
+
+ #stopContainerObservation(): void {
+ this.containerCleanup?.()
+ this.containerCleanup = null
+ }
+
/**
- * Observe a DOM container for element counting and mutation tracking.
- * Replaces any previously observed container.
+ * Register a DOM container for element counting and mutation tracking.
+ * Observation is active only while the panel is visible.
*/
observeContainer(element: HTMLElement): () => void {
- this.containerCleanup?.()
- this.containerCleanup = this.manager.observeContainer(element)
- return this.containerCleanup
+ if (this.containerElement !== element) {
+ this.#stopContainerObservation()
+ this.containerElement = element
+ }
+ this.#startContainerObservation()
+
+ return () => {
+ if (this.containerElement !== element) return
+ this.#stopContainerObservation()
+ this.containerElement = null
+ }
}
}
diff --git a/packages/storybook-addon-performance-panel/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx
index 9ec3526..714e4eb 100644
--- a/packages/storybook-addon-performance-panel/performance-panel.tsx
+++ b/packages/storybook-addon-performance-panel/performance-panel.tsx
@@ -1677,6 +1677,35 @@ function ConnectedPanelContent({storyId}: {storyId: string}) {
)
}
+/**
+ * Keep preview collection synchronized with panel visibility.
+ *
+ * This component must remain outside AddonPanel because Storybook freezes
+ * AddonPanel children while the panel is inactive.
+ */
+function PanelVisibilityController({active}: {active: boolean}) {
+ const {previewInitialized} = useStorybookState()
+ const emit = useChannel(
+ {
+ [PERF_EVENTS.REQUEST_PANEL_VISIBILITY]: () => {
+ emit(PERF_EVENTS.PANEL_VISIBILITY, active)
+ },
+ },
+ [active],
+ )
+
+ React.useEffect(() => {
+ if (!previewInitialized) return undefined
+
+ emit(PERF_EVENTS.PANEL_VISIBILITY, active)
+ return () => {
+ emit(PERF_EVENTS.PANEL_VISIBILITY, false)
+ }
+ }, [active, emit, previewInitialized])
+
+ return null
+}
+
/**
* Outer panel content - handles storyId gating.
*
@@ -1688,14 +1717,11 @@ function ConnectedPanelContent({storyId}: {storyId: string}) {
* - viewMode: Detect docs vs story mode
*
* @component
- * @param props.active - Whether the panel tab is currently selected
* @private
*/
-function PanelContent({active}: {active: boolean}) {
+function PanelContent() {
const {storyId, previewInitialized, viewMode, refId} = useStorybookState()
- if (!active) return null
-
if (!storyId) {
return (
@@ -1772,9 +1798,12 @@ interface PerformancePanelProps {
export function PerformancePanel({active}: PerformancePanelProps) {
return (
-
-
-
+ <>
+
+
+
+
+ >
)
}
diff --git a/packages/storybook-addon-performance-panel/react/performance-decorator.tsx b/packages/storybook-addon-performance-panel/react/performance-decorator.tsx
index cdea68c..3926b14 100644
--- a/packages/storybook-addon-performance-panel/react/performance-decorator.tsx
+++ b/packages/storybook-addon-performance-panel/react/performance-decorator.tsx
@@ -174,7 +174,7 @@ export const PerformanceProvider = memo(function PerformanceProvider({
const core = coreRef.current
if (!enabled || !contentRef.current || !core) return
return core.observeContainer(contentRef.current)
- }, [enabled])
+ }, [enabled, storyId])
// Memoize context value to avoid unnecessary re-renders
const contextValue: ReportReactRenderProfileContextValue = useCallback(