From 5b1ad352d72b95b2a38caaecee9da2f0dda94a1e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 00:21:23 +0000 Subject: [PATCH 1/5] perf: stream metrics only while panel is visible --- .changeset/visible-panel-updates.md | 5 ++ ...rmance-decorator-universal.browser.test.ts | 38 +++++++++-- .../core/performance-types.ts | 2 + .../core/preview-core.ts | 63 ++++++++++++------- .../performance-panel.tsx | 10 +++ 5 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 .changeset/visible-panel-updates.md diff --git a/.changeset/visible-panel-updates.md b/.changeset/visible-panel-updates.md new file mode 100644 index 0000000..7414bcb --- /dev/null +++ b/.changeset/visible-panel-updates.md @@ -0,0 +1,5 @@ +--- +'@github-ui/storybook-addon-performance-panel': patch +--- + +Stop periodic 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/__tests__/performance-decorator-universal.browser.test.ts b/packages/storybook-addon-performance-panel/__tests__/performance-decorator-universal.browser.test.ts index 9e1f7fa..0bee3ea 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,44 @@ 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)) }) - 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)) + }) + + 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(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) + 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', () => { @@ -191,13 +216,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 +266,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/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index a528fdf..2292491 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -116,6 +116,8 @@ 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 live updates based on panel visibility */ + PANEL_VISIBILITY: `${ADDON_ID}/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..6ce07c9 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 @@ -131,8 +131,14 @@ export class PerformanceMonitorCore { 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 +151,18 @@ export class PerformanceMonitorCore { this.reset() } + const handlePanelVisibility = (visible: boolean) => { + if (visible) { + emitMetrics() + this.#startLiveUpdates(emitMetrics) + } else { + this.#stopLiveUpdates() + } + } + 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 +172,13 @@ 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) } /** @@ -179,15 +188,7 @@ export class PerformanceMonitorCore { stop(): void { this.manager.stop() - if (this.metricsIntervalId != null) { - clearInterval(this.metricsIntervalId) - this.metricsIntervalId = null - } - - if (this.sparklineIntervalId != null) { - clearInterval(this.sparklineIntervalId) - this.sparklineIntervalId = null - } + this.#stopLiveUpdates() for (const cleanup of this.channelCleanups) { cleanup() @@ -204,6 +205,24 @@ 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 + } + } + /** * Observe a DOM container for element counting and mutation tracking. * Replaces any previously observed container. diff --git a/packages/storybook-addon-performance-panel/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx index 9ec3526..a326118 100644 --- a/packages/storybook-addon-performance-panel/performance-panel.tsx +++ b/packages/storybook-addon-performance-panel/performance-panel.tsx @@ -1693,6 +1693,16 @@ function ConnectedPanelContent({storyId}: {storyId: string}) { */ function PanelContent({active}: {active: boolean}) { const {storyId, previewInitialized, viewMode, refId} = useStorybookState() + const emit = useChannel({}) + + React.useEffect(() => { + if (!previewInitialized) return undefined + + emit(PERF_EVENTS.PANEL_VISIBILITY, active) + return () => { + emit(PERF_EVENTS.PANEL_VISIBILITY, false) + } + }, [active, emit, previewInitialized, storyId]) if (!active) return null From 75c43551739af34fd1f5f5632dcc0976934efca2 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 02:17:38 +0000 Subject: [PATCH 2/5] fix: preserve live updates across story changes --- ...rmance-decorator-universal.browser.test.ts | 27 +++++++++++++ .../performance-decorator.browser.test.tsx | 39 +++++++++++++++---- .../core/performance-types.ts | 2 + .../core/preview-core.ts | 2 + .../performance-panel.tsx | 8 +++- 5 files changed, 68 insertions(+), 10 deletions(-) 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 0bee3ea..5559b06 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 @@ -88,6 +88,7 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => { 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('does not emit metrics periodically while the panel is hidden', async () => { @@ -162,6 +163,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( 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..c5a718a 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.find( + (call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY, + ) + 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,9 @@ describe('performance-decorator', () => { , ) - // Wait for initial count and metrics emission - await new Promise(resolve => setTimeout(resolve, 600)) + 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/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index 2292491..f6d7f0c 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -118,6 +118,8 @@ export const PERF_EVENTS = { REQUEST_METRICS: `${ADDON_ID}/request-metrics`, /** Panel → Decorator: Start or stop live updates 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 6ce07c9..6af4094 100644 --- a/packages/storybook-addon-performance-panel/core/preview-core.ts +++ b/packages/storybook-addon-performance-panel/core/preview-core.ts @@ -179,6 +179,8 @@ export class PerformanceMonitorCore { channel.off(PERF_EVENTS.INSPECT_ELEMENT, handleInspectElement) }, ] + + channel.emit(PERF_EVENTS.REQUEST_PANEL_VISIBILITY) } /** diff --git a/packages/storybook-addon-performance-panel/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx index a326118..aea8e67 100644 --- a/packages/storybook-addon-performance-panel/performance-panel.tsx +++ b/packages/storybook-addon-performance-panel/performance-panel.tsx @@ -1693,7 +1693,11 @@ function ConnectedPanelContent({storyId}: {storyId: string}) { */ function PanelContent({active}: {active: boolean}) { const {storyId, previewInitialized, viewMode, refId} = useStorybookState() - const emit = useChannel({}) + const emit = useChannel({ + [PERF_EVENTS.REQUEST_PANEL_VISIBILITY]: () => { + emit(PERF_EVENTS.PANEL_VISIBILITY, active) + }, + }) React.useEffect(() => { if (!previewInitialized) return undefined @@ -1702,7 +1706,7 @@ function PanelContent({active}: {active: boolean}) { return () => { emit(PERF_EVENTS.PANEL_VISIBILITY, false) } - }, [active, emit, previewInitialized, storyId]) + }, [active, emit, previewInitialized]) if (!active) return null From 119dbc41347f068f4ca0222d7a460e1bd7f77708 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 04:20:20 +0000 Subject: [PATCH 3/5] fix: report current panel visibility --- .../performance-panel.browser.test.tsx | 73 +++++++++++++++++++ .../performance-panel.tsx | 57 +++++++++------ 2 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 packages/storybook-addon-performance-panel/__tests__/performance-panel.browser.test.tsx 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/performance-panel.tsx b/packages/storybook-addon-performance-panel/performance-panel.tsx index aea8e67..714e4eb 100644 --- a/packages/storybook-addon-performance-panel/performance-panel.tsx +++ b/packages/storybook-addon-performance-panel/performance-panel.tsx @@ -1678,26 +1678,21 @@ function ConnectedPanelContent({storyId}: {storyId: string}) { } /** - * Outer panel content - handles storyId gating. - * - * ConnectedPanelContent stores all profiler data and cleans up old entries - * after confirming the new story's profiler is registered. This avoids - * losing mount data due to timing issues with React's key-based remounting. - * - * Uses Storybook lifecycle hooks for: - * - viewMode: Detect docs vs story mode + * Keep preview collection synchronized with panel visibility. * - * @component - * @param props.active - Whether the panel tab is currently selected - * @private + * This component must remain outside AddonPanel because Storybook freezes + * AddonPanel children while the panel is inactive. */ -function PanelContent({active}: {active: boolean}) { - const {storyId, previewInitialized, viewMode, refId} = useStorybookState() - const emit = useChannel({ - [PERF_EVENTS.REQUEST_PANEL_VISIBILITY]: () => { - emit(PERF_EVENTS.PANEL_VISIBILITY, active) +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 @@ -1708,7 +1703,24 @@ function PanelContent({active}: {active: boolean}) { } }, [active, emit, previewInitialized]) - if (!active) return null + return null +} + +/** + * Outer panel content - handles storyId gating. + * + * ConnectedPanelContent stores all profiler data and cleans up old entries + * after confirming the new story's profiler is registered. This avoids + * losing mount data due to timing issues with React's key-based remounting. + * + * Uses Storybook lifecycle hooks for: + * - viewMode: Detect docs vs story mode + * + * @component + * @private + */ +function PanelContent() { + const {storyId, previewInitialized, viewMode, refId} = useStorybookState() if (!storyId) { return ( @@ -1786,9 +1798,12 @@ interface PerformancePanelProps { export function PerformancePanel({active}: PerformancePanelProps) { return ( - - - + <> + + + + + ) } From ece99cef67a26364ffcbfe9b75e315f575cfda7f Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 04:23:34 +0000 Subject: [PATCH 4/5] perf: collect browser metrics while panel is visible --- .changeset/automatic-collection.md | 6 +++ .../README.md | 6 +++ ...rmance-decorator-universal.browser.test.ts | 3 ++ .../performance-decorator.browser.test.tsx | 12 +++-- .../core/performance-types.ts | 2 +- .../core/preview-core.ts | 51 +++++++++++++------ .../react/performance-decorator.tsx | 2 +- 7 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 .changeset/automatic-collection.md diff --git a/.changeset/automatic-collection.md b/.changeset/automatic-collection.md new file mode 100644 index 0000000..b6ec374 --- /dev/null +++ b/.changeset/automatic-collection.md @@ -0,0 +1,6 @@ +--- +'@github-ui/storybook-addon-performance-panel': minor +--- + +Pause browser collectors while the panel is hidden, preserving full diagnostics +without exposing separate collection modes. 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 5559b06..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 @@ -101,6 +101,7 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => { 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 () => { @@ -115,6 +116,7 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => { 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() @@ -122,6 +124,7 @@ describe('withPerformanceMonitor (universal / web-component usage)', () => { 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)) 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 c5a718a..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 @@ -136,9 +136,9 @@ describe('performance-decorator', () => {
Test
, ) - const visibilityCall = mockChannel.on.mock.calls.find( - (call: unknown[]) => call[0] === PERF_EVENTS.PANEL_VISIBILITY, - ) + 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) @@ -377,6 +377,12 @@ describe('performance-decorator', () => { , ) + 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() diff --git a/packages/storybook-addon-performance-panel/core/performance-types.ts b/packages/storybook-addon-performance-panel/core/performance-types.ts index f6d7f0c..ae9ea7a 100644 --- a/packages/storybook-addon-performance-panel/core/performance-types.ts +++ b/packages/storybook-addon-performance-panel/core/performance-types.ts @@ -116,7 +116,7 @@ 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 live updates based on panel visibility */ + /** 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`, diff --git a/packages/storybook-addon-performance-panel/core/preview-core.ts b/packages/storybook-addon-performance-panel/core/preview-core.ts index 6af4094..e62014b 100644 --- a/packages/storybook-addon-performance-panel/core/preview-core.ts +++ b/packages/storybook-addon-performance-panel/core/preview-core.ts @@ -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,14 +124,10 @@ 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.manager.start() + this.panelVisible = false const emitMetrics = () => { const computed = this.manager.computeMetrics() @@ -152,11 +150,16 @@ export class PerformanceMonitorCore { } 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() } } @@ -188,17 +191,17 @@ export class PerformanceMonitorCore { * Removes channel listeners and clears intervals. */ stop(): void { - this.manager.stop() - + this.panelVisible = false this.#stopLiveUpdates() + this.#stopContainerObservation() + this.manager.stop() 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. */ @@ -225,14 +228,32 @@ export class PerformanceMonitorCore { } } + #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/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( From 42baa3b3af132e95292ba0a3317ec0642fe54ea4 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 3 Aug 2026 12:09:56 +0000 Subject: [PATCH 5/5] docs: consolidate hidden-panel release note --- .changeset/automatic-collection.md | 6 ------ .changeset/visible-panel-updates.md | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 .changeset/automatic-collection.md diff --git a/.changeset/automatic-collection.md b/.changeset/automatic-collection.md deleted file mode 100644 index b6ec374..0000000 --- a/.changeset/automatic-collection.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@github-ui/storybook-addon-performance-panel': minor ---- - -Pause browser collectors while the panel is hidden, preserving full diagnostics -without exposing separate collection modes. diff --git a/.changeset/visible-panel-updates.md b/.changeset/visible-panel-updates.md index 7414bcb..c5d4ed5 100644 --- a/.changeset/visible-panel-updates.md +++ b/.changeset/visible-panel-updates.md @@ -1,5 +1,5 @@ --- -'@github-ui/storybook-addon-performance-panel': patch +'@github-ui/storybook-addon-performance-panel': minor --- -Stop periodic metric transport and sparkline sampling while the performance panel is hidden. \ No newline at end of file +Pause browser collectors, DOM observation, metric transport, and sparkline sampling while the performance panel is hidden. \ No newline at end of file