Skip to content

Comments

feat: split decorator architecture and add universal entry points#41

Draft
mattcosta7 wants to merge 8 commits intomainfrom
universal-split
Draft

feat: split decorator architecture and add universal entry points#41
mattcosta7 wants to merge 8 commits intomainfrom
universal-split

Conversation

@mattcosta7
Copy link
Member

Summary

Split the single React-only withPerformanceMonitor decorator into a two-layer architecture, enabling framework-agnostic performance monitoring.


What changed

Core extraction (core/preview-core.ts)

New PerformanceMonitorCore class that encapsulates all browser-level metric collection:

  • Frame timing, CLS, long tasks, input latency, memory, DOM mutation tracking
  • Channel event wiring (metrics emission, reset, inspect element)
  • Container observation via MutationObserver

Decorator split

decorators/universal.ts — Framework-agnostic decorator using PerformanceMonitorCore directly (no React dependency). Observes #storybook-root container.

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

Entry Purpose
index-universal.ts Universal (non-React) public API
index-react.ts React-specific API with Profiler support
preset-universal.ts Storybook preset for non-React frameworks
preview-universal.ts Preview with universal decorator only

Other changes

  • react/performance-decorator.tsx — Simplified to use PerformanceMonitorCore
  • performance-panel.tsxrefId keying for composed storybook isolation
  • package.json — New subpath exports: ./universal, ./react, ./components
  • index.ts — Updated to type-only exports (public API moved to index-react.ts)
  • New test suite: performance-decorator-universal.browser.test.ts (11 tests x 3 browsers)

Testing

  • 51 test files, 738 assertions passing (all browsers)
  • Build clean (attw + publint)
  • ESLint clean

Depends on

@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 21:52 — with GitHub Actions Inactive
@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 21:57 — with GitHub Actions Inactive
@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 21:59 — with GitHub Actions Inactive
@mattcosta7 mattcosta7 self-assigned this Feb 22, 2026
@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 22:01 — with GitHub Actions Inactive
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)
@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 22:15 — with GitHub Actions Inactive
@mattcosta7 mattcosta7 mentioned this pull request Feb 22, 2026
@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 22:36 — with GitHub Actions Inactive
@mattcosta7 mattcosta7 temporarily deployed to github-pages-preview February 22, 2026 22:39 — with GitHub Actions Inactive
@Rajdeepc
Copy link

Rajdeepc commented Feb 23, 2026

@mattcosta7 This is exactly the architecture we were hoping for. We integrated the addon into Spectrum Web Components (Storybook 10 + @storybook/web-components-vite) and had to write a ~600-line custom decorator to bridge the gap -- a WebComponentMetricsCollector class that reimplements the browser-level metric collection and channel communication, loaded alongside the /preset entry point to avoid the React decorator.
The core/adapter split here maps cleanly to what we ended up building manually.

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.
Happy to validate the universal entry points against our setup once this is ready for testing. We'd adopt it immediately.

Copy link

@Rajdeepc Rajdeepc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 68 to 75
const createdCore = core
requestAnimationFrame(() => {
if (getActiveCore() !== createdCore) return

const root = document.getElementById('storybook-root')
if (root) {
createdCore.observeContainer(root)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes! I added a mutation observer for this here: 0b64f49

return dirname(fileURLToPath(import.meta.url))
}

export function managerEntries(entry: string[] = []): string[] {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

})
}

return storyFn() as unknown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looses type safety. Can you consider using a generic parameter `Decorator to get proper typing without the cast?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants