Scope buffered performance metrics to each story - #156
Conversation
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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_INTERACTIONScap:#interactionMapshrinks to 100 entries, but#seenInteractionIdsgrows 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
startTimeis set when a script/resource element is inserted—therefore precede both this reset and the subsequent collectorstart(), 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
There was a problem hiding this comment.
Review details
Suppressed comments (3)
packages/storybook-addon-performance-panel/react/performance-decorator.tsx:158
useLayoutEffectruns after descendant commit callbacks, includingProfiler.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 anupdate, andReactProfilerCollector.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
maxKnownInteractionIdacross those IDs, so the estimate includes interactions that the explicit stopped-period logic is meant to exclude;Math.maxthen 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 oneelementsarray, 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
There was a problem hiding this comment.
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.Profilercommit callbacks. On a story switch,contextValuealready 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
interactionIdis 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
#epochMsis used both as the buffered-entry cutoff and as the story-time origin, butstart()advances it on every same-story resume while existing element records are preserved. After anenabledstop/start, old records remain relative to the original start and new records become relative to the resume time, so ordering andlargestRenderTimeno 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
There was a problem hiding this comment.
Review details
Suppressed comments (3)
packages/storybook-addon-performance-panel/react/performance-decorator.tsx:158
- On a
storyIdupdate, descendant React Profiler callbacks run during the commit/layout phase before this provider’s layout effect. They report the new story through the newcontextValue, 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
#epochMsis advanced on everystart(), while collected elements are retained acrossstop()/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
Summary
Start the v1.2 stack by isolating buffered browser performance entries to the active story.
Stack
Validation
npm run tscnpm run lint(one pre-existing unused suppression warning)