Repository navigation
feat: split decorator architecture and add universal entry points - #41
Conversation
Extract PerformanceMonitorCore from the React-specific decorator into a shared core (core/preview-core.ts) that can be used by any framework. Decorator split: - decorators/universal.ts: framework-agnostic decorator using PerformanceMonitorCore directly (browser-level metrics) - decorators/react.tsx: React.Profiler bridge that layers on top for React-specific render profiling - preview.ts: two-decorator stack [withPerformanceMonitor, withReactProfiler] New entry points: - index-universal.ts: universal (non-React) public API - index-react.ts: React-specific public API with Profiler support - preset-universal.ts: Storybook preset for non-React frameworks - preview-universal.ts: preview with universal decorator only - react/performance-decorator.tsx simplified to use PerformanceMonitorCore - performance-panel.tsx: refId keying for composed storybook isolation - package.json: new subpath exports (./universal, ./react, ./components) - New test suite for universal decorator
- Add packages/examples-html with Counter, LayoutThrashing, and WebComponent stories demonstrating the universal (non-React) addon - Update docs site: introduction, setup, collectors, metrics pages with HTML/universal usage details - Update storybook-config with HTML storybook sidebar links - Update CI workflow to build HTML storybook - Add root package.json scripts for HTML examples - Add universal-split changeset (minor)
97bb487 to
af41249
Compare
|
@mattcosta7 This is exactly the architecture we were hoping for. We integrated the addon into Spectrum Web Components (Storybook 10 + One suggestion: It would be helpful if the metrics schema gracefully handled missing groups -- e.g., if the universal decorator doesn't emit React Profiler fields, the panel could hide that section entirely rather than requiring them to be present as zeroes. That way framework-specific adapters don't need to be aware of each other's fields. |
Rajdeepc
left a comment
There was a problem hiding this comment.
This is the right architecture and liked the direction. Let me know if you want to collaborate to get this in. Would be very helpful for non-React projects.
| const createdCore = core | ||
| requestAnimationFrame(() => { | ||
| if (getActiveCore() !== createdCore) return | ||
|
|
||
| const root = document.getElementById('storybook-root') | ||
| if (root) { | ||
| createdCore.observeContainer(root) | ||
| } |
There was a problem hiding this comment.
The stale-core guard is good, but if the story renders but #storybook-root hasn't been populated yet (e.g., async rendering frameworks), root will be null and container observation silently fails. Can you possible add a retry with MutationObserver on document.body or requestAnimationFrame loop (with a bounded retry count) to catch late-mounting roots. This matters for frameworks like Vue/Svelte that may mount asynchronously.
There was a problem hiding this comment.
yes! I added a mutation observer for this here: 0b64f49
| return dirname(fileURLToPath(import.meta.url)) | ||
| } | ||
|
|
||
| export function managerEntries(entry: string[] = []): string[] { |
There was a problem hiding this comment.
No preview entries? The panel UI will be shown but decorator won't be auto a-pplied. We have to manually add withperformanceMonitor in preview.ts. React presets auto-applies both. Can you consider adding a previewAnnotation export to preset-universal.ts that points to preview-universal.ts for parity.
There was a problem hiding this comment.
I believe (and could be wrong on the ideal state)
When we declare exports in package.json like https://github.com/github/storybook-addon-performance-panel/pull/41/changes#diff-3896b9c4823a797986b71e274747e4f96dfa597fb4aa8519ef99c253718289d4R45-R48 storybook add /preview entry via autodiscovery to the addon path in main.ts and resolves via the the decorator which handles the auto-loading behavior
Adding previewAnnotations() on top of that causes a duplicate symbol crash:
Identifier 'preview_universal_11416' has already been declaredThe bundler sees two resolution paths to the same module (one absolute path from previewAnnotations, one package export from auto-discovery) and can't deduplicate them.
The React preset works the same way — [managerEntries()] only, no previewAnnotations(). Both rely on auto-discovery via the exports map
I believe that should work for each case?
I have 2 storybooks building now react and html in the pages deployment on the pr
as well as some updated docs here
| }) | ||
| } | ||
|
|
||
| return storyFn() as unknown |
There was a problem hiding this comment.
This looses type safety. Can you consider using a generic parameter `Decorator to get proper typing without the cast?
There was a problem hiding this comment.
Yep! I think this was just to silence an eslint warning, we can disable instead - storyFn() returns any since it's framework specified - but safety wise disabling seems more correct than an unknown cast - so done!
📦 Canary Release
npm install @github-ui/storybook-addon-performance-panel@1.0.1-canary.d6c4f43Published from 9f574eb |
@Rajdeepc - let me know how this canary looks for y'all! |
There was a problem hiding this comment.
Pull request overview
This PR introduces framework-agnostic performance monitoring by splitting the React-only decorator into a two-layer architecture with universal browser-level metrics collection and optional React profiling.
Changes:
- Extracted core performance monitoring into a framework-agnostic
PerformanceMonitorCoreclass - Split decorators into universal (browser metrics) and React-specific (Profiler) layers
- Added new entry points (
./universal,./react,./components) for different framework support
Reviewed changes
Copilot reviewed 39 out of 41 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
packages/storybook-addon-performance-panel/core/preview-core.ts |
New core class extracting browser-level metrics collection from React decorator |
packages/storybook-addon-performance-panel/decorators/universal.ts |
Framework-agnostic decorator using PerformanceMonitorCore directly |
packages/storybook-addon-performance-panel/decorators/react.tsx |
React.Profiler bridge wrapping stories with profiler integration |
packages/storybook-addon-performance-panel/preview.ts |
Updated to use decorator stack: universal + React profiler |
packages/storybook-addon-performance-panel/performance-panel.tsx |
Added hasReactProfiler state and conditional rendering for React section |
packages/storybook-addon-performance-panel/package.json |
New subpath exports for universal, react, and components entries |
packages/storybook-addon-performance-panel/index-universal.ts |
Universal-only public API entry point |
packages/storybook-addon-performance-panel/index-react.ts |
React-specific component exports |
packages/examples-html/ |
New HTML docs storybook demonstrating universal entry usage |
packages/website/app/content/ |
Documentation updates for universal entry point |
Comments suppressed due to low confidence (2)
packages/storybook-addon-performance-panel/tsconfig.json:1
- The removal of the
excludearray may cause TypeScript to include thedistdirectory in compilation. Ifdistis auto-generated during the build process, this could lead to compilation errors or slower type-checking. Consider re-adding\"exclude\": [\"dist\"]to prevent these issues.
packages/storybook-addon-performance-panel/react/performance-decorator.tsx:1 - The return value of
observeContainer()is not being stored or returned from theuseLayoutEffect. According to the core implementation,observeContainer()returns a cleanup function that should be returned from the effect to properly disconnect the observer when the component unmounts or dependencies change.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export default start | ||
|
|
||
| // Public API for manual per-story usage | ||
| // Public API for manual per-story usage (framework-agnostic) |
There was a problem hiding this comment.
The comment states this is "framework-agnostic" but the file exports withPerformanceMonitor from the universal decorator while the default preview still uses both decorators (universal + React). This may confuse users about which exports are truly framework-agnostic. Consider clarifying that this public API is universal-only while the preset/preview includes React support.
| // Public API for manual per-story usage (framework-agnostic) | |
| // Public API for manual per-story usage using universal, framework-agnostic decorators. | |
| // Framework-specific integrations (e.g. React) are provided via the preset/preview exports. |
| storyId: string | ||
| children: React.ReactNode | ||
| }) { | ||
| const profilerId = `Story(${storyId})` |
There was a problem hiding this comment.
The profiler ID format Story(${storyId}) is inconsistent with the pattern used elsewhere in the codebase. The React collector and previous implementation don't show this exact format being used. Verify this ID matches what the CollectorManager expects for profiler tracking.
| const profilerId = `Story(${storyId})` | |
| const profilerId = storyId |
| return { | ||
| ...state, | ||
| // Reset hasReactProfiler on story change — it will be set again if a PROFILER_UPDATE arrives | ||
| hasReactProfiler: currentProfilers ? state.hasReactProfiler : false, |
There was a problem hiding this comment.
The logic for resetting hasReactProfiler on story change is flawed. It checks if currentProfilers exists (truthy), but should check if the current story has profilers. If switching from a story with profilers to one without, currentProfilers could still be truthy (containing the old story's profilers), causing hasReactProfiler to remain true incorrectly. Should be: hasReactProfiler: currentProfilers && currentProfilers.length > 0 ? state.hasReactProfiler : false
| hasReactProfiler: currentProfilers ? state.hasReactProfiler : false, | |
| hasReactProfiler: currentProfilers && currentProfilers.length > 0 ? state.hasReactProfiler : false, |
| // ============================================================================ | ||
|
|
||
| /** Maximum time (ms) to wait for #storybook-root to appear in the DOM. */ | ||
| const ROOT_DISCOVERY_TIMEOUT_MS = 5_000 |
There was a problem hiding this comment.
A 5-second timeout for root discovery is excessive for most use cases. Even slow-mounting frameworks typically render within 1-2 seconds. This creates a potential 5-second observer leak if the root never appears. Consider reducing to 2000ms (2 seconds) to balance async framework support with resource cleanup.
| const ROOT_DISCOVERY_TIMEOUT_MS = 5_000 | |
| const ROOT_DISCOVERY_TIMEOUT_MS = 2_000 |
Hi @mattcosta7 this looks good from my side. I’ve enabled it in our core setup and it’s working well. As we roll it out more broadly, I’ll keep an eye out for any additional extension points or gaps and follow up with concrete suggestions if needed. When you’re ready to cut a major release, it would be great to share this more widely with the community as well — I think there’s real value here beyond just our use case. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 41 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| observeContainer(element: HTMLElement): void { | ||
| this.containerCleanup?.() | ||
| this.containerCleanup = this.manager.observeContainer(element) |
There was a problem hiding this comment.
The observeContainer method receives a cleanup function from this.manager.observeContainer() but doesn't return it. This prevents callers (like the React decorator's useLayoutEffect) from properly cleaning up the MutationObserver when the effect re-runs or the component unmounts. Change the return type from void to () => void and return the cleanup function.
| observeContainer(element: HTMLElement): void { | |
| this.containerCleanup?.() | |
| this.containerCleanup = this.manager.observeContainer(element) | |
| observeContainer(element: HTMLElement): () => void { | |
| this.containerCleanup?.() | |
| const cleanup = this.manager.observeContainer(element) | |
| this.containerCleanup = cleanup | |
| return cleanup |
| useLayoutEffect(() => { | ||
| const manager = managerRef.current | ||
| if (!enabled || !contentRef.current || !manager) return | ||
| return manager.observeContainer(contentRef.current) | ||
| const core = coreRef.current | ||
| if (!enabled || !contentRef.current || !core) return | ||
| core.observeContainer(contentRef.current) | ||
| }, [enabled]) |
There was a problem hiding this comment.
The observeContainer method returns a cleanup function, but it's not being returned from the useLayoutEffect. This will prevent proper cleanup when the effect re-runs or when the component unmounts. The cleanup function should be returned to ensure the MutationObserver is properly disconnected.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@mattcosta7 I've opened a new pull request, #52, to work on those changes. Once the pull request is ready, I'll request review from you. |
…ator Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com>
Fix missing MutationObserver cleanup in useLayoutEffect
@Rajdeepc We'll take a look at improving the setup to allow for extensible decorators / metrics panels per framework (happy to take PRs on that too)!
yes! We'll be trying to share it more and happy to see it used more broadly! Definitely keep us in the loop with your usage and value you find, as well as any gaps! |

Summary
Split the single React-only
withPerformanceMonitordecorator into a two-layer architecture, enabling framework-agnostic performance monitoring.What changed
Core extraction (
core/preview-core.ts)New
PerformanceMonitorCoreclass that encapsulates all browser-level metric collection:Decorator split
decorators/universal.ts— Framework-agnostic decorator usingPerformanceMonitorCoredirectly (no React dependency). Observes#storybook-rootcontainer.decorators/react.tsx— React.Profiler bridge that layers on top for React-specific render profiling.preview.ts— Two-decorator stack:[withPerformanceMonitor, withReactProfiler]New entry points
index-universal.tsindex-react.tspreset-universal.tspreview-universal.tsOther changes
react/performance-decorator.tsx— Simplified to usePerformanceMonitorCoreperformance-panel.tsx—refIdkeying for composed storybook isolationpackage.json— New subpath exports:./universal,./react,./componentsindex.ts— Updated to type-only exports (public API moved toindex-react.ts)performance-decorator-universal.browser.test.ts(11 tests x 3 browsers)Testing
Depends on