Skip to content

Scope buffered performance metrics to each story - #156

Merged
mattcosta7 merged 5 commits into
mainfrom
v1.2/01-story-scoped-metrics
Aug 3, 2026
Merged

Scope buffered performance metrics to each story#156
mattcosta7 merged 5 commits into
mainfrom
v1.2/01-story-scoped-metrics

Conversation

@mattcosta7

@mattcosta7 mattcosta7 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Start the v1.2 stack by isolating buffered browser performance entries to the active story.

  • filter buffered CLS, LoAF, Event Timing, FID, paint, resource, and Element Timing entries by collector start/reset epoch
  • report Element Timing as elapsed story time instead of an absolute page timestamp
  • prefer the story-local native interaction count, with legacy Chromium ID spacing only as a fallback
  • add stale-entry and reset regression coverage

Stack

  1. This PR: story-scoped buffered metrics
  2. Pause frame metrics while previews are hidden #157: frame lifecycle and hidden-preview handling
  3. Pause performance work while the panel is hidden #158: visibility-driven collection and live-update lifecycle

Validation

  • 54 test files / 759 tests across Chromium, Firefox, and WebKit
  • npm run tsc
  • npm run lint (one pre-existing unused suppression warning)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Scopes buffered performance metrics to the active story/reset epoch.

Changes:

  • Filters stale Paint, CLS, LoAF, Event Timing, FID, resource, and Element Timing entries.
  • Reports Element Timing relative to story start and tracks story-local interactions.
  • Adds stale-entry and reset regression tests.
Show a summary per file
File Description
.changeset/story-scoped-metrics.md Records the minor release change.
collectors/paint-collector.ts Filters stale paint and resource entries.
collectors/long-animation-frame-collector.ts Filters stale LoAF entries.
collectors/layout-shift-collector.ts Filters stale layout shifts.
collectors/input-collector.ts Scopes input entries and interaction counts.
collectors/element-timing-collector.ts Reports story-relative timing.
collectors/__tests__/paint-collector.browser.test.ts Tests stale paint/resource filtering.
collectors/__tests__/long-animation-frame-collector.browser.test.ts Tests stale LoAF filtering.
collectors/__tests__/layout-shift-collector.browser.test.ts Tests stale shifts and reset epochs.
collectors/__tests__/input-collector.browser.test.ts Tests story-scoped interactions.
collectors/__tests__/element-timing-collector.browser.test.ts Tests relative Element Timing.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 11/11 changed files
  • Comments generated: 2
  • Review effort level: Balanced

@mattcosta7
mattcosta7 marked this pull request as draft August 3, 2026 00:31
Copilot AI review requested due to automatic review settings August 3, 2026 02:12
@mattcosta7
mattcosta7 temporarily deployed to github-pages-preview August 3, 2026 02:13 — with GitHub Actions Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (2)

packages/storybook-addon-performance-panel/collectors/input-collector.ts:250

  • This set retains every interaction ID for the entire story, so the new deduplication defeats the existing #MAX_INTERACTIONS cap: #interactionMap shrinks to 100 entries, but #seenInteractionIds grows without bound. Use bounded/compact deduplication state (for example, a watermark/range representation if the supported browsers' IDs are monotonic), or otherwise prune it with an explicit late-entry policy.
    if (!this.#seenInteractionIds.has(interactionId)) {
      this.#seenInteractionIds.add(interactionId)
      this.#interactionCount++

packages/storybook-addon-performance-panel/react/performance-decorator.tsx:158

  • The story boundary is recorded in this parent layout effect, after the new story's DOM has committed. Entries initiated by that commit—especially Resource Timing entries whose startTime is set when a script/resource element is inserted—therefore precede both this reset and the subsequent collector start(), so the new epoch filters out work belonging to the new story. Capture a single story epoch before rendering/committing the new story and pass it through reset/start to the buffered collectors.
    if (core.storyId !== storyId) {
      core.reset()
    }
  • Files reviewed: 15/15 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings August 3, 2026 04:24
@mattcosta7
mattcosta7 temporarily deployed to github-pages-preview August 3, 2026 04:25 — with GitHub Actions Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (3)

packages/storybook-addon-performance-panel/react/performance-decorator.tsx:158

  • useLayoutEffect runs after descendant commit callbacks, including Profiler.onRender. On a reused provider, the new story's first profiler sample is therefore recorded before this reset; because the profiler itself is already mounted, that sample is typically an update, and ReactProfilerCollector.reset() clears it. Perform the story transition/reset before descendants can report the new commit (or make the report path transition atomically) so the initial render of each story is retained.
    if (core.storyId !== storyId) {
      core.reset()
    }

packages/storybook-addon-performance-panel/collectors/input-collector.ts:257

  • This range-based estimate spans collector restarts. If interactions occur while collection is stopped, the next observed ID advances maxKnownInteractionId across those IDs, so the estimate includes interactions that the explicit stopped-period logic is meant to exclude; Math.max then overrides even an accurate native delta. Accumulate the estimate per active start/stop segment (resetting each segment's min/max) so gaps while stopped are not counted.
    this.#minKnownInteractionId = Math.min(this.#minKnownInteractionId, interactionId)
    this.#maxKnownInteractionId = Math.max(this.#maxKnownInteractionId, interactionId)
    this.#interactionCountEstimate =
      (this.#maxKnownInteractionId - this.#minKnownInteractionId) / InputCollector.#INTERACTION_ID_INCREMENT + 1

packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts:152

  • The collector retains existing records across same-story stop/start cycles, but start() advances #epochMs. Subtracting that latest epoch here mixes different time origins in one elements array, so an element observed after re-enabling collection can appear earlier than an element from the original story start. Keep a separate story-origin timestamp (advanced only on reset) from the buffered-entry cutoff (advanced on each start), and subtract the story origin here.
    if (entryTime < this.#epochMs) return

    const renderTime = entryTime - this.#epochMs
  • Files reviewed: 15/15 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mattcosta7
mattcosta7 marked this pull request as ready for review August 3, 2026 12:22
Copilot AI review requested due to automatic review settings August 3, 2026 12:22
@mattcosta7
mattcosta7 temporarily deployed to github-pages-preview August 3, 2026 12:22 — with GitHub Actions Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (3)

packages/storybook-addon-performance-panel/react/performance-decorator.tsx:158

  • This reset runs in the provider's layout effect, after descendant React.Profiler commit callbacks. On a story switch, contextValue already reports the new story's commit into the existing manager, and this call then clears that freshly recorded mount/render data. Reset or replace the core before the new profiled subtree commits, and cover the switch by asserting that story B's initial profiler metrics survive.
    if (core.storyId !== storyId) {
      core.reset()
    }

packages/storybook-addon-performance-panel/collectors/input-collector.ts:257

  • interactionId is only guaranteed to uniquely group an interaction; the Event Timing API does not guarantee a dense sequence with a fixed increment of seven. Deriving a count from the numeric range can therefore over- or under-count on conforming implementations, and gaps spanning a stop/start interval are incorrectly counted as active-story interactions. Track unique observed IDs independently of the pruned latency map (clearing them on reset), while continuing to prefer the scoped native-count delta when available.
    this.#minKnownInteractionId = Math.min(this.#minKnownInteractionId, interactionId)
    this.#maxKnownInteractionId = Math.max(this.#maxKnownInteractionId, interactionId)
    this.#interactionCountEstimate =
      (this.#maxKnownInteractionId - this.#minKnownInteractionId) / InputCollector.#INTERACTION_ID_INCREMENT + 1

packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts:152

  • The same #epochMs is used both as the buffered-entry cutoff and as the story-time origin, but start() advances it on every same-story resume while existing element records are preserved. After an enabled stop/start, old records remain relative to the original start and new records become relative to the resume time, so ordering and largestRenderTime no longer represent elapsed story time. Keep separate story and observation epochs: advance the observation cutoff on each start, but only reset the story origin when metrics are reset for a new story.
    // Ignore entries from before the current epoch (stale after reset/restart)
    if (entryTime < this.#epochMs) return

    const renderTime = entryTime - this.#epochMs
  • Files reviewed: 15/15 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (3)

packages/storybook-addon-performance-panel/react/performance-decorator.tsx:158

  • On a storyId update, descendant React Profiler callbacks run during the commit/layout phase before this provider’s layout effect. They report the new story through the new contextValue, and this reset then clears that just-recorded render/update data (ReactProfilerCollector.reset() preserves only mount count/duration). A story that does not render again can therefore report zero render/update metrics. Reset the old story before new-story profiler callbacks are accepted, or make the transition reset preserve records already tagged with the new story ID.
    if (core.storyId !== storyId) {
      core.reset()
    }

packages/storybook-addon-performance-panel/collectors/element-timing-collector.ts:157

  • #epochMs is advanced on every start(), while collected elements are retained across stop()/start(). Subtracting that mutable value here gives post-restart entries a new origin while older entries still use the original origin, so same-story restarts (including the hidden-panel lifecycle in the stack) understate later Element Timing values instead of reporting elapsed story time. Keep a stable story/reset epoch for the subtraction and use a separate observation cutoff to reject entries from paused periods.
    const renderTime = entryTime - this.#epochMs

    const record: ElementTimingRecord = {
      identifier: entry.identifier || 'unnamed',
      renderTime,
      loadTime: entry.loadTime > 0 ? Math.max(0, entry.loadTime - this.#epochMs) : 0,

packages/storybook-addon-performance-panel/collectors/input-collector.ts:258

  • When the native count is unavailable, this range assumes every interaction ID between the minimum and maximum belongs to an active collection segment. These bounds survive stop()/start(), so interactions generated while the observer is disconnected create an ID gap that is counted when the next visible entry arrives; unlike the native-offset path, paused interactions are therefore included. Commit the current segment’s estimate on stop and start fresh per-segment ID bounds on restart while retaining the accumulated total.
    if (this.#nativeInteractionCountBaseline === null) {
      this.#minKnownInteractionId = Math.min(this.#minKnownInteractionId, interactionId)
      this.#maxKnownInteractionId = Math.max(this.#maxKnownInteractionId, interactionId)
      this.#interactionCountEstimate =
        (this.#maxKnownInteractionId - this.#minKnownInteractionId) / InputCollector.#INTERACTION_ID_INCREMENT + 1
  • Files reviewed: 15/15 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mattcosta7 mattcosta7 self-assigned this Aug 3, 2026
@mattcosta7
mattcosta7 merged commit e41f2b7 into main Aug 3, 2026
17 checks passed
@mattcosta7
mattcosta7 deleted the v1.2/01-story-scoped-metrics branch August 3, 2026 12:47
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