@react-perfscope/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -171,6 +171,19 @@ type FrameStats = {
171
171
  /** Approximate frames dropped across the recording (vs a 60fps budget). */
172
172
  droppedFrames: number;
173
173
  };
174
+ /** A component whose instances were unmounted but stayed retained (not
175
+ * garbage-collected) with a count that climbed across the recording — i.e. a
176
+ * suspected leak. Identifies WHICH component leaks and HOW MANY instances; the
177
+ * retainer chain (who holds them) is not observable from in-page JS. */
178
+ type LeakSuspect = {
179
+ component: string;
180
+ /** Total instances of this component unmounted during the recording. */
181
+ unmounted: number;
182
+ /** Unmounted instances still alive at the end (unmounted − collected). */
183
+ retained: number;
184
+ /** Growth rate of the retained-instance floor, in instances per minute. */
185
+ retainedSlopePerMin: number;
186
+ };
174
187
  interface RecordingResult {
175
188
  signals: Signal[];
176
189
  startedAt: number;
@@ -181,12 +194,15 @@ interface RecordingResult {
181
194
  /** requestAnimationFrame timestamps captured during the recording, for
182
195
  * frame-rate / jank analysis. Attached at finalize. */
183
196
  frames?: number[];
197
+ /** Components with a sustained rise in retained-after-unmount instances —
198
+ * suspected leaks. Attached at finalize; absent when none or unsupported. */
199
+ leakSuspects?: LeakSuspect[];
184
200
  }
185
201
  /** Collectors are usually keyed by the SignalKind they emit. A few (heap,
186
202
  * self-profiling) drive a side-channel instead of emitting signals; `'heap'`
187
203
  * widens the kind for the heap sampler, which attaches a time series via
188
204
  * finalize rather than pushing signals. */
189
- type CollectorKind = SignalKind | 'heap' | 'frame';
205
+ type CollectorKind = SignalKind | 'heap' | 'frame' | 'leak';
190
206
  interface Collector {
191
207
  readonly kind: CollectorKind;
192
208
  activate(emit: (signal: Signal) => void): void;
@@ -200,6 +216,9 @@ interface Recorder {
200
216
  use(collector: Collector): void;
201
217
  }
202
218
 
219
+ /** Hard cap on retained signals; the oldest are dropped past this. Exported so
220
+ * downstream tests can assert the buffer never grows beyond it. */
221
+ declare const BUFFER_CAP = 10000;
203
222
  declare function createRecorder(): Recorder;
204
223
 
205
224
  /** A parsed source map (the JSON object), as returned by a FetchMap. */
@@ -241,6 +260,44 @@ declare function markSelfRequest(url: string): void;
241
260
  /** True when `url` was fetched by react-perfscope itself. */
242
261
  declare function isSelfRequest(url: string): boolean;
243
262
 
263
+ type Capabilities = Record<SignalKind, boolean>;
264
+ /** Which signal kinds the current browser can actually measure. The render and
265
+ * forced-reflow collectors are pure JS (React fibers / DOM-API instrumentation)
266
+ * so they work everywhere; the rest depend on PerformanceObserver entry types
267
+ * that Firefox and Safari do not implement (long tasks, layout shift, INP). */
268
+ declare function detectCapabilities(): Capabilities;
269
+ /** Signal kinds the current browser cannot measure — for telling the user a
270
+ * tab is empty because of the platform, not because nothing happened. */
271
+ declare function unsupportedKinds(): SignalKind[];
272
+
273
+ /** One reading of how many unmounted instances of a component are still
274
+ * retained (alive, not yet garbage-collected) at a point in time. */
275
+ type LeakSample = {
276
+ at: number;
277
+ retained: number;
278
+ };
279
+ /**
280
+ * Decide whether a component's retained-instance series indicates a leak.
281
+ *
282
+ * Mirrors {@link analyzeHeapTrend}: GC makes the raw retained count a sawtooth
283
+ * (instances pile up, then a sweep collects a batch), so the reliable signal is
284
+ * the *floor* — the post-GC troughs. We segment the series, take each segment's
285
+ * minimum, and regress those minima against time.
286
+ *
287
+ * A rising slope alone is not enough: a one-time step (a screen mounts N
288
+ * long-lived instances once, then plateaus) regresses positive but is not a
289
+ * leak. So we additionally require the recent half of the floor to still be
290
+ * rising, and gate on a minimum net growth in instances. This is what makes the
291
+ * detector robust to React StrictMode's intentional mount→unmount→remount
292
+ * churn, whose discarded fibers get collected and keep the floor flat.
293
+ *
294
+ * Returns null when there are too few samples to say anything.
295
+ */
296
+ declare function analyzeLeakTrend(samples: LeakSample[]): {
297
+ leaking: boolean;
298
+ slopePerMin: number;
299
+ } | null;
300
+
244
301
  type AnchorSignal = InteractionSignal | LongTaskSignal;
245
302
  /** How strongly a member is linked to its episode anchor. `caused` means a
246
303
  * member's source location matches one of the anchor's hot frames — a real
@@ -421,4 +478,4 @@ interface SelfProfilingCollector extends Collector {
421
478
  */
422
479
  declare function createSelfProfilingCollector(): SelfProfilingCollector;
423
480
 
424
- export { type AnchorSignal, type Collector, type CollectorKind, type CommitCause, type CreateSourceMapResolverOptions, type Episode, type EpisodeMember, type FetchMap, type ForcedReflowSignal, type FpsSample, type FrameCollector, type FrameStats, type HeapCollector, type HeapSample, type HeapTrend, type HeapTrendClass, type InpPhase, type InteractionCollector, type InteractionSignal, type LayoutShiftSignal, type LinkConfidence, type LongTaskAttribution, type LongTaskScript, type LongTaskSignal, type NetworkSignal, type ProfilerFrame, type ProfilerSample, type ProfilerStack, type ProfilerTrace, type Recorder, type RecordingResult, type RenderReason, type RenderSignal, type Signal, type SignalKind, type SourceMapResolver, type StackFrame, type WebVitalSignal, analyzeFrames, analyzeHeapTrend, attachLazyStack, attributeLongTaskSignals, attributeWindow, correlate, createForcedReflowCollector, createFrameCollector, createHeapCollector, createInteractionCollector, createLayoutShiftCollector, createLongTasksCollector, createNetworkCollector, createRecorder, createSelfProfilingCollector, createSourceMapResolver, createWebVitalsCollector, isSelfRequest, isUserResource, markSelfRequest, parseStack, resolveFrame };
481
+ export { type AnchorSignal, BUFFER_CAP, type Capabilities, type Collector, type CollectorKind, type CommitCause, type CreateSourceMapResolverOptions, type Episode, type EpisodeMember, type FetchMap, type ForcedReflowSignal, type FpsSample, type FrameCollector, type FrameStats, type HeapCollector, type HeapSample, type HeapTrend, type HeapTrendClass, type InpPhase, type InteractionCollector, type InteractionSignal, type LayoutShiftSignal, type LeakSample, type LeakSuspect, type LinkConfidence, type LongTaskAttribution, type LongTaskScript, type LongTaskSignal, type NetworkSignal, type ProfilerFrame, type ProfilerSample, type ProfilerStack, type ProfilerTrace, type Recorder, type RecordingResult, type RenderReason, type RenderSignal, type Signal, type SignalKind, type SourceMapResolver, type StackFrame, type WebVitalSignal, analyzeFrames, analyzeHeapTrend, analyzeLeakTrend, attachLazyStack, attributeLongTaskSignals, attributeWindow, correlate, createForcedReflowCollector, createFrameCollector, createHeapCollector, createInteractionCollector, createLayoutShiftCollector, createLongTasksCollector, createNetworkCollector, createRecorder, createSelfProfilingCollector, createSourceMapResolver, createWebVitalsCollector, detectCapabilities, isSelfRequest, isUserResource, markSelfRequest, parseStack, resolveFrame, unsupportedKinds };
package/dist/index.d.ts CHANGED
@@ -171,6 +171,19 @@ type FrameStats = {
171
171
  /** Approximate frames dropped across the recording (vs a 60fps budget). */
172
172
  droppedFrames: number;
173
173
  };
174
+ /** A component whose instances were unmounted but stayed retained (not
175
+ * garbage-collected) with a count that climbed across the recording — i.e. a
176
+ * suspected leak. Identifies WHICH component leaks and HOW MANY instances; the
177
+ * retainer chain (who holds them) is not observable from in-page JS. */
178
+ type LeakSuspect = {
179
+ component: string;
180
+ /** Total instances of this component unmounted during the recording. */
181
+ unmounted: number;
182
+ /** Unmounted instances still alive at the end (unmounted − collected). */
183
+ retained: number;
184
+ /** Growth rate of the retained-instance floor, in instances per minute. */
185
+ retainedSlopePerMin: number;
186
+ };
174
187
  interface RecordingResult {
175
188
  signals: Signal[];
176
189
  startedAt: number;
@@ -181,12 +194,15 @@ interface RecordingResult {
181
194
  /** requestAnimationFrame timestamps captured during the recording, for
182
195
  * frame-rate / jank analysis. Attached at finalize. */
183
196
  frames?: number[];
197
+ /** Components with a sustained rise in retained-after-unmount instances —
198
+ * suspected leaks. Attached at finalize; absent when none or unsupported. */
199
+ leakSuspects?: LeakSuspect[];
184
200
  }
185
201
  /** Collectors are usually keyed by the SignalKind they emit. A few (heap,
186
202
  * self-profiling) drive a side-channel instead of emitting signals; `'heap'`
187
203
  * widens the kind for the heap sampler, which attaches a time series via
188
204
  * finalize rather than pushing signals. */
189
- type CollectorKind = SignalKind | 'heap' | 'frame';
205
+ type CollectorKind = SignalKind | 'heap' | 'frame' | 'leak';
190
206
  interface Collector {
191
207
  readonly kind: CollectorKind;
192
208
  activate(emit: (signal: Signal) => void): void;
@@ -200,6 +216,9 @@ interface Recorder {
200
216
  use(collector: Collector): void;
201
217
  }
202
218
 
219
+ /** Hard cap on retained signals; the oldest are dropped past this. Exported so
220
+ * downstream tests can assert the buffer never grows beyond it. */
221
+ declare const BUFFER_CAP = 10000;
203
222
  declare function createRecorder(): Recorder;
204
223
 
205
224
  /** A parsed source map (the JSON object), as returned by a FetchMap. */
@@ -241,6 +260,44 @@ declare function markSelfRequest(url: string): void;
241
260
  /** True when `url` was fetched by react-perfscope itself. */
242
261
  declare function isSelfRequest(url: string): boolean;
243
262
 
263
+ type Capabilities = Record<SignalKind, boolean>;
264
+ /** Which signal kinds the current browser can actually measure. The render and
265
+ * forced-reflow collectors are pure JS (React fibers / DOM-API instrumentation)
266
+ * so they work everywhere; the rest depend on PerformanceObserver entry types
267
+ * that Firefox and Safari do not implement (long tasks, layout shift, INP). */
268
+ declare function detectCapabilities(): Capabilities;
269
+ /** Signal kinds the current browser cannot measure — for telling the user a
270
+ * tab is empty because of the platform, not because nothing happened. */
271
+ declare function unsupportedKinds(): SignalKind[];
272
+
273
+ /** One reading of how many unmounted instances of a component are still
274
+ * retained (alive, not yet garbage-collected) at a point in time. */
275
+ type LeakSample = {
276
+ at: number;
277
+ retained: number;
278
+ };
279
+ /**
280
+ * Decide whether a component's retained-instance series indicates a leak.
281
+ *
282
+ * Mirrors {@link analyzeHeapTrend}: GC makes the raw retained count a sawtooth
283
+ * (instances pile up, then a sweep collects a batch), so the reliable signal is
284
+ * the *floor* — the post-GC troughs. We segment the series, take each segment's
285
+ * minimum, and regress those minima against time.
286
+ *
287
+ * A rising slope alone is not enough: a one-time step (a screen mounts N
288
+ * long-lived instances once, then plateaus) regresses positive but is not a
289
+ * leak. So we additionally require the recent half of the floor to still be
290
+ * rising, and gate on a minimum net growth in instances. This is what makes the
291
+ * detector robust to React StrictMode's intentional mount→unmount→remount
292
+ * churn, whose discarded fibers get collected and keep the floor flat.
293
+ *
294
+ * Returns null when there are too few samples to say anything.
295
+ */
296
+ declare function analyzeLeakTrend(samples: LeakSample[]): {
297
+ leaking: boolean;
298
+ slopePerMin: number;
299
+ } | null;
300
+
244
301
  type AnchorSignal = InteractionSignal | LongTaskSignal;
245
302
  /** How strongly a member is linked to its episode anchor. `caused` means a
246
303
  * member's source location matches one of the anchor's hot frames — a real
@@ -421,4 +478,4 @@ interface SelfProfilingCollector extends Collector {
421
478
  */
422
479
  declare function createSelfProfilingCollector(): SelfProfilingCollector;
423
480
 
424
- export { type AnchorSignal, type Collector, type CollectorKind, type CommitCause, type CreateSourceMapResolverOptions, type Episode, type EpisodeMember, type FetchMap, type ForcedReflowSignal, type FpsSample, type FrameCollector, type FrameStats, type HeapCollector, type HeapSample, type HeapTrend, type HeapTrendClass, type InpPhase, type InteractionCollector, type InteractionSignal, type LayoutShiftSignal, type LinkConfidence, type LongTaskAttribution, type LongTaskScript, type LongTaskSignal, type NetworkSignal, type ProfilerFrame, type ProfilerSample, type ProfilerStack, type ProfilerTrace, type Recorder, type RecordingResult, type RenderReason, type RenderSignal, type Signal, type SignalKind, type SourceMapResolver, type StackFrame, type WebVitalSignal, analyzeFrames, analyzeHeapTrend, attachLazyStack, attributeLongTaskSignals, attributeWindow, correlate, createForcedReflowCollector, createFrameCollector, createHeapCollector, createInteractionCollector, createLayoutShiftCollector, createLongTasksCollector, createNetworkCollector, createRecorder, createSelfProfilingCollector, createSourceMapResolver, createWebVitalsCollector, isSelfRequest, isUserResource, markSelfRequest, parseStack, resolveFrame };
481
+ export { type AnchorSignal, BUFFER_CAP, type Capabilities, type Collector, type CollectorKind, type CommitCause, type CreateSourceMapResolverOptions, type Episode, type EpisodeMember, type FetchMap, type ForcedReflowSignal, type FpsSample, type FrameCollector, type FrameStats, type HeapCollector, type HeapSample, type HeapTrend, type HeapTrendClass, type InpPhase, type InteractionCollector, type InteractionSignal, type LayoutShiftSignal, type LeakSample, type LeakSuspect, type LinkConfidence, type LongTaskAttribution, type LongTaskScript, type LongTaskSignal, type NetworkSignal, type ProfilerFrame, type ProfilerSample, type ProfilerStack, type ProfilerTrace, type Recorder, type RecordingResult, type RenderReason, type RenderSignal, type Signal, type SignalKind, type SourceMapResolver, type StackFrame, type WebVitalSignal, analyzeFrames, analyzeHeapTrend, analyzeLeakTrend, attachLazyStack, attributeLongTaskSignals, attributeWindow, correlate, createForcedReflowCollector, createFrameCollector, createHeapCollector, createInteractionCollector, createLayoutShiftCollector, createLongTasksCollector, createNetworkCollector, createRecorder, createSelfProfilingCollector, createSourceMapResolver, createWebVitalsCollector, detectCapabilities, isSelfRequest, isUserResource, markSelfRequest, parseStack, resolveFrame, unsupportedKinds };
package/dist/index.js CHANGED
@@ -201,6 +201,74 @@ function parseStack(raw) {
201
201
  return frames;
202
202
  }
203
203
 
204
+ // src/capabilities.ts
205
+ function supportedEntryTypes() {
206
+ const PO = globalThis.PerformanceObserver;
207
+ return PO?.supportedEntryTypes;
208
+ }
209
+ function observerSupported(needed) {
210
+ const types = supportedEntryTypes();
211
+ if (!Array.isArray(types)) return true;
212
+ return needed.some((t) => types.includes(t));
213
+ }
214
+ function detectCapabilities() {
215
+ return {
216
+ "forced-reflow": true,
217
+ render: true,
218
+ "web-vital": true,
219
+ "layout-shift": observerSupported(["layout-shift"]),
220
+ "long-task": observerSupported(["long-animation-frame", "longtask"]),
221
+ interaction: observerSupported(["event"]),
222
+ network: observerSupported(["resource"])
223
+ };
224
+ }
225
+ function unsupportedKinds() {
226
+ const caps = detectCapabilities();
227
+ return Object.keys(caps).filter((k) => !caps[k]);
228
+ }
229
+
230
+ // src/leak-trend.ts
231
+ var MIN_SAMPLES = 4;
232
+ var FLOOR_SEGMENTS = 8;
233
+ var MIN_NET_GROWTH = 3;
234
+ function slope(points) {
235
+ const n = points.length;
236
+ if (n < 2) return 0;
237
+ let sx = 0;
238
+ let sy = 0;
239
+ let sxy = 0;
240
+ let sxx = 0;
241
+ for (const { x, y } of points) {
242
+ sx += x;
243
+ sy += y;
244
+ sxy += x * y;
245
+ sxx += x * x;
246
+ }
247
+ const denom = n * sxx - sx * sx;
248
+ if (denom === 0) return 0;
249
+ return (n * sxy - sx * sy) / denom;
250
+ }
251
+ function analyzeLeakTrend(samples) {
252
+ if (samples.length < MIN_SAMPLES) return null;
253
+ const t0 = samples[0].at;
254
+ const segSize = Math.max(1, Math.ceil(samples.length / FLOOR_SEGMENTS));
255
+ const floorPoints = [];
256
+ for (let i = 0; i < samples.length; i += segSize) {
257
+ const seg = samples.slice(i, i + segSize);
258
+ let min = Infinity;
259
+ for (const s of seg) min = Math.min(min, s.retained);
260
+ const mid = seg[Math.floor(seg.length / 2)];
261
+ floorPoints.push({ x: (mid.at - t0) / 6e4, y: min });
262
+ }
263
+ const slopePerMin = slope(floorPoints);
264
+ const spanMin = (samples[samples.length - 1].at - t0) / 6e4;
265
+ const projectedGrowth = slopePerMin * spanMin;
266
+ const recent = floorPoints.slice(Math.floor(floorPoints.length / 2));
267
+ const recentSlope = recent.length >= 2 ? slope(recent) : slopePerMin;
268
+ const leaking = projectedGrowth >= MIN_NET_GROWTH && recentSlope > 0;
269
+ return { leaking, slopePerMin };
270
+ }
271
+
204
272
  // src/correlate.ts
205
273
  var FRAME_MS = 16;
206
274
  function isAnchor(signal) {
@@ -690,19 +758,19 @@ function createWebVitalsCollector() {
690
758
  // src/collectors/heap.ts
691
759
  var SAMPLE_INTERVAL_MS = 250;
692
760
  var MAX_SAMPLES = 2e4;
693
- var MIN_SAMPLES = 4;
694
- var FLOOR_SEGMENTS = 8;
761
+ var MIN_SAMPLES2 = 4;
762
+ var FLOOR_SEGMENTS2 = 8;
695
763
  var MB = 1024 * 1024;
696
764
  var GROWING_SLOPE = 1 * MB;
697
765
  var LEAK_SLOPE = 5 * MB;
698
- var MIN_NET_GROWTH = 2 * MB;
766
+ var MIN_NET_GROWTH2 = 2 * MB;
699
767
  function readMemory() {
700
768
  const perf = globalThis.performance;
701
769
  const mem = perf?.memory;
702
770
  if (!mem || typeof mem.usedJSHeapSize !== "number") return null;
703
771
  return mem;
704
772
  }
705
- function slope(points) {
773
+ function slope2(points) {
706
774
  const n = points.length;
707
775
  if (n < 2) return 0;
708
776
  let sx = 0;
@@ -720,9 +788,9 @@ function slope(points) {
720
788
  return (n * sxy - sx * sy) / denom;
721
789
  }
722
790
  function analyzeHeapTrend(samples) {
723
- if (samples.length < MIN_SAMPLES) return null;
791
+ if (samples.length < MIN_SAMPLES2) return null;
724
792
  const t0 = samples[0].at;
725
- const segSize = Math.max(1, Math.ceil(samples.length / FLOOR_SEGMENTS));
793
+ const segSize = Math.max(1, Math.ceil(samples.length / FLOOR_SEGMENTS2));
726
794
  const floorPoints = [];
727
795
  for (let i = 0; i < samples.length; i += segSize) {
728
796
  const seg = samples.slice(i, i + segSize);
@@ -731,13 +799,13 @@ function analyzeHeapTrend(samples) {
731
799
  const mid = seg[Math.floor(seg.length / 2)];
732
800
  floorPoints.push({ x: (mid.at - t0) / 6e4, y: min });
733
801
  }
734
- const slopeBytesPerMin = slope(floorPoints);
802
+ const slopeBytesPerMin = slope2(floorPoints);
735
803
  const spanMin = (samples[samples.length - 1].at - t0) / 6e4;
736
804
  const projectedGrowth = slopeBytesPerMin * spanMin;
737
805
  const recent = floorPoints.slice(Math.floor(floorPoints.length / 2));
738
- const recentSlope = recent.length >= 2 ? slope(recent) : slopeBytesPerMin;
806
+ const recentSlope = recent.length >= 2 ? slope2(recent) : slopeBytesPerMin;
739
807
  let classification = "stable";
740
- const sustained = projectedGrowth >= MIN_NET_GROWTH && recentSlope >= GROWING_SLOPE;
808
+ const sustained = projectedGrowth >= MIN_NET_GROWTH2 && recentSlope >= GROWING_SLOPE;
741
809
  if (sustained) {
742
810
  classification = slopeBytesPerMin >= LEAK_SLOPE && recentSlope >= LEAK_SLOPE ? "leak-suspected" : "growing";
743
811
  }
@@ -1066,8 +1134,10 @@ function createSelfProfilingCollector() {
1066
1134
  };
1067
1135
  }
1068
1136
  export {
1137
+ BUFFER_CAP,
1069
1138
  analyzeFrames,
1070
1139
  analyzeHeapTrend,
1140
+ analyzeLeakTrend,
1071
1141
  attachLazyStack,
1072
1142
  attributeLongTaskSignals,
1073
1143
  attributeWindow,
@@ -1083,10 +1153,12 @@ export {
1083
1153
  createSelfProfilingCollector,
1084
1154
  createSourceMapResolver,
1085
1155
  createWebVitalsCollector,
1156
+ detectCapabilities,
1086
1157
  isSelfRequest,
1087
1158
  isUserResource,
1088
1159
  markSelfRequest,
1089
1160
  parseStack,
1090
- resolveFrame
1161
+ resolveFrame,
1162
+ unsupportedKinds
1091
1163
  };
1092
1164
  //# sourceMappingURL=index.js.map