@react-perfscope/core 0.1.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.
@@ -0,0 +1,367 @@
1
+ /// <reference lib="es2015" />
2
+ /// <reference lib="dom" />
3
+
4
+ import { SourceMapInput } from '@jridgewell/trace-mapping';
5
+
6
+ type StackFrame = {
7
+ file: string;
8
+ line: number;
9
+ col: number;
10
+ fnName?: string;
11
+ };
12
+ type ForcedReflowSignal = {
13
+ kind: 'forced-reflow';
14
+ at: number;
15
+ duration: number;
16
+ stack: StackFrame[];
17
+ };
18
+ type LayoutShiftSignal = {
19
+ kind: 'layout-shift';
20
+ at: number;
21
+ value: number;
22
+ sources: DOMRect[];
23
+ /**
24
+ * Parallel array to `sources`. Each entry is the rect of the source node
25
+ * BEFORE the shift, in document coords. `null` when the source had no
26
+ * previous rect (newly inserted element). Lets consumers draw a "moved
27
+ * from → to" arrow during overlay rendering.
28
+ */
29
+ previousSources?: (DOMRect | null)[];
30
+ };
31
+ type LongTaskScript = {
32
+ /** What kind of entry point ran this script: 'event-listener',
33
+ * 'user-callback', 'resolve-promise', 'reject-promise', 'classic-script',
34
+ * 'module-script', etc. (from the LoAF spec). */
35
+ invokerType: string;
36
+ /** Human-readable invoker, e.g. "BUTTON#go.onclick" or a callback name. */
37
+ invoker: string;
38
+ sourceURL: string;
39
+ sourceFunctionName: string;
40
+ /** Character offset of the function in its source, or -1 when unknown. */
41
+ charPosition: number;
42
+ /** Wall-clock ms this script occupied. */
43
+ duration: number;
44
+ };
45
+ /**
46
+ * One hot user-source location inside a long task, derived from JS
47
+ * Self-Profiling samples. Answers "which of MY functions burned the time",
48
+ * which LoAF cannot do for React-delegated events (it only sees React's
49
+ * single root dispatcher). `frame` is the raw served-file position; resolve
50
+ * it through source maps for the original location.
51
+ */
52
+ type LongTaskAttribution = {
53
+ frame: StackFrame;
54
+ /** Fraction (0..1) of this task's in-window samples whose leaf user frame
55
+ * was this one. */
56
+ selfRatio: number;
57
+ /** Number of in-window samples attributed to this frame. */
58
+ sampleCount: number;
59
+ };
60
+ type LongTaskSignal = {
61
+ kind: 'long-task';
62
+ at: number;
63
+ duration: number;
64
+ stack: StackFrame[];
65
+ /** Per-script attribution from the Long Animation Frame API. Absent when
66
+ * LoAF is unsupported (falls back to the legacy `longtask` entry, which
67
+ * gives duration only). */
68
+ scripts?: LongTaskScript[];
69
+ /** Main-thread blocking time reported by LoAF, when available. */
70
+ blockingDuration?: number;
71
+ /** Hottest user-source frames inside this task, from JS Self-Profiling.
72
+ * Sorted by sampleCount desc. Absent when self-profiling is unavailable
73
+ * (no Profiler API or missing Document-Policy header). */
74
+ attribution?: LongTaskAttribution[];
75
+ };
76
+ type WebVitalSignal = {
77
+ kind: 'web-vital';
78
+ name: 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB';
79
+ value: number;
80
+ };
81
+ type NetworkSignal = {
82
+ kind: 'network';
83
+ url: string;
84
+ startedAt: number;
85
+ duration: number;
86
+ size: number;
87
+ blocking: boolean;
88
+ };
89
+ type InteractionSignal = {
90
+ kind: 'interaction';
91
+ /** Event startTime — when the user input arrived. */
92
+ at: number;
93
+ /** Event type that defined the interaction latency (click, pointerup, keydown…). */
94
+ eventType: string;
95
+ /** Best-effort CSS-ish selector of the event target. */
96
+ target?: string;
97
+ /** Total interaction latency: input → handlers → next paint (ms). */
98
+ duration: number;
99
+ /** startTime → processingStart: main thread was busy before handlers ran. */
100
+ inputDelay: number;
101
+ /** processingStart → processingEnd: the event handlers themselves. */
102
+ processing: number;
103
+ /** processingEnd → next paint: re-render + paint after handlers. */
104
+ presentation: number;
105
+ /** Hottest user-source frames during the processing window, from JS
106
+ * Self-Profiling. Absent when self-profiling is unavailable or found none. */
107
+ attribution?: LongTaskAttribution[];
108
+ };
109
+ type RenderReason = 'mount' | 'state' | 'props' | 'parent';
110
+ type RenderSignal = {
111
+ kind: 'render';
112
+ at: number;
113
+ component: string;
114
+ reason: RenderReason;
115
+ duration: number;
116
+ /** Prop keys that changed since the previous render (reason === 'props'). */
117
+ changedProps?: string[];
118
+ /** Groups every render emitted from the same commit so the UI can show
119
+ * one cascade (root + the components it re-rendered) as a unit. */
120
+ commitId: number;
121
+ /** Fiber depth from the committed root — used to indent the cascade. */
122
+ depth: number;
123
+ };
124
+ type Signal = ForcedReflowSignal | LayoutShiftSignal | LongTaskSignal | WebVitalSignal | NetworkSignal | RenderSignal | InteractionSignal;
125
+ type SignalKind = Signal['kind'];
126
+ /** One heap-usage reading. `at` shares the performance.now() clock used by
127
+ * signal timestamps, so it lines up with the timeline x-axis. */
128
+ type HeapSample = {
129
+ at: number;
130
+ /** usedJSHeapSize — live JS objects, in bytes. */
131
+ used: number;
132
+ /** totalJSHeapSize — allocated heap, in bytes. */
133
+ total: number;
134
+ };
135
+ type HeapTrendClass = 'stable' | 'growing' | 'leak-suspected';
136
+ type HeapTrend = {
137
+ classification: HeapTrendClass;
138
+ /** Slope of the heap "floor" (post-GC troughs), in bytes per minute. A
139
+ * steadily rising floor is the signal that memory is being retained. */
140
+ slopeBytesPerMin: number;
141
+ };
142
+ /** One windowed frame-rate reading. */
143
+ type FpsSample = {
144
+ at: number;
145
+ fps: number;
146
+ };
147
+ type FrameStats = {
148
+ /** Windowed FPS series across the recording. */
149
+ series: FpsSample[];
150
+ /** Lowest windowed FPS — the worst sustained dip. */
151
+ minFps: number;
152
+ /** Longest single inter-frame gap (worst hitch), in ms. */
153
+ longestFrameMs: number;
154
+ /** Approximate frames dropped across the recording (vs a 60fps budget). */
155
+ droppedFrames: number;
156
+ };
157
+ interface RecordingResult {
158
+ signals: Signal[];
159
+ startedAt: number;
160
+ duration: number;
161
+ /** Heap-usage time series, present only when performance.memory is
162
+ * available (Chromium). Attached at finalize, not part of the signal buffer. */
163
+ heapSamples?: HeapSample[];
164
+ /** requestAnimationFrame timestamps captured during the recording, for
165
+ * frame-rate / jank analysis. Attached at finalize. */
166
+ frames?: number[];
167
+ }
168
+ /** Collectors are usually keyed by the SignalKind they emit. A few (heap,
169
+ * self-profiling) drive a side-channel instead of emitting signals; `'heap'`
170
+ * widens the kind for the heap sampler, which attaches a time series via
171
+ * finalize rather than pushing signals. */
172
+ type CollectorKind = SignalKind | 'heap' | 'frame';
173
+ interface Collector {
174
+ readonly kind: CollectorKind;
175
+ activate(emit: (signal: Signal) => void): void;
176
+ deactivate(): void;
177
+ }
178
+ interface Recorder {
179
+ start(): void;
180
+ stop(): RecordingResult;
181
+ isRecording(): boolean;
182
+ onSignal(cb: (signal: Signal) => void): () => void;
183
+ use(collector: Collector): void;
184
+ }
185
+
186
+ declare function createRecorder(): Recorder;
187
+
188
+ /** A parsed source map (the JSON object), as returned by a FetchMap. */
189
+ type RawSourceMap = SourceMapInput;
190
+ type FetchMap = (file: string) => Promise<RawSourceMap | null>;
191
+ declare function resolveFrame(frame: StackFrame, fetchMap: FetchMap): Promise<StackFrame>;
192
+ /**
193
+ * Attach a lazy `stack` getter to `target` that parses `raw` on first access
194
+ * and memoizes the result. Use this from collectors to defer parseStack cost
195
+ * until a consumer actually reads `signal.stack`.
196
+ *
197
+ * `skipTopFrames` drops the leading N parsed frames — useful for collectors
198
+ * that wrap a patched function (the wrapper itself shows up as the topmost
199
+ * frame, but it's noise from the user's perspective).
200
+ */
201
+ declare function attachLazyStack(target: object, raw: string | undefined, skipTopFrames?: number): void;
202
+ interface SourceMapResolver {
203
+ /** Resolve a parsed StackFrame to its original source position. Falls back to the input frame on any failure. */
204
+ resolve(frame: StackFrame): Promise<StackFrame>;
205
+ }
206
+ interface CreateSourceMapResolverOptions {
207
+ /** Override the global fetch. Useful for tests and non-browser environments. */
208
+ fetch?: typeof globalThis.fetch;
209
+ }
210
+ /**
211
+ * Create a SourceMap resolver that fetches `.map` files from the live URL
212
+ * referenced in each source file's `//# sourceMappingURL=` directive.
213
+ * Caches the parsed source map per source URL so repeated resolves against
214
+ * the same file are O(1) after the first.
215
+ *
216
+ * Returns the input frame on any failure (network error, missing map, etc.).
217
+ */
218
+ declare function createSourceMapResolver(opts?: CreateSourceMapResolverOptions): SourceMapResolver;
219
+ declare function parseStack(raw: string | undefined): StackFrame[];
220
+
221
+ declare function createLongTasksCollector(): Collector;
222
+
223
+ declare function createForcedReflowCollector(): Collector;
224
+
225
+ declare function createLayoutShiftCollector(): Collector;
226
+
227
+ declare function createNetworkCollector(): Collector;
228
+
229
+ declare function createWebVitalsCollector(): Collector;
230
+
231
+ /**
232
+ * Estimate whether memory is being retained across the recording.
233
+ *
234
+ * GC makes raw heap usage a sawtooth, so the peak is noisy. The reliable
235
+ * signal is the *floor* — the post-GC troughs. We split the series into
236
+ * contiguous segments, take the minimum of each (its trough), and regress
237
+ * those minima against time.
238
+ *
239
+ * A rising overall slope is necessary but NOT sufficient: a one-time step
240
+ * (heap warms up early, then plateaus) also regresses to a positive slope, and
241
+ * flagging that as a leak is a false positive — the classic "idle recording
242
+ * reads abnormal" bug. A real leak keeps climbing, so we additionally require
243
+ * the *recent* half of the floor to still be rising. We also gate on a minimum
244
+ * net growth, since over a short window a sub-MB wobble extrapolates to a steep
245
+ * per-minute slope.
246
+ *
247
+ * Returns null when there are too few samples to say anything.
248
+ */
249
+ declare function analyzeHeapTrend(samples: HeapSample[]): HeapTrend | null;
250
+ interface HeapCollector extends Collector {
251
+ /** Attach the sampled heap series to the recording result. Returns the
252
+ * input untouched when nothing was sampled (unsupported browser). */
253
+ finalize(result: RecordingResult): RecordingResult;
254
+ }
255
+ /**
256
+ * Samples `performance.memory` on an interval for the duration of a recording
257
+ * and attaches the series via `finalize`. It is a Collector so the recorder
258
+ * drives its start/stop, but it emits no signals — the series is a separate
259
+ * track (see RecordingResult.heapSamples), so a busy session's signal buffer
260
+ * can't evict it. No-ops entirely when performance.memory is unavailable
261
+ * (non-Chromium), leaving `heapSamples` absent so the UI can show a fallback.
262
+ */
263
+ declare function createHeapCollector(): HeapCollector;
264
+
265
+ interface InteractionCollector extends Collector {
266
+ /** Group buffered Event Timing entries into one interaction each and append
267
+ * them to the result. Returns the input untouched when none qualified. */
268
+ finalize(result: RecordingResult): RecordingResult;
269
+ }
270
+ /**
271
+ * Records Event Timing entries during a recording and, on finalize, turns each
272
+ * user interaction (grouped by interactionId) into one signal carrying the INP
273
+ * latency breakdown: input delay → processing → presentation. The longest
274
+ * event in a group defines the interaction's latency (matching how INP is
275
+ * attributed). Emits no live signals — interactions are assembled at finalize,
276
+ * then the self-profiling collector attributes the processing window to the
277
+ * developer's hot functions.
278
+ */
279
+ declare function createInteractionCollector(): InteractionCollector;
280
+
281
+ /**
282
+ * Turn raw requestAnimationFrame timestamps into a frame-rate picture:
283
+ * a windowed FPS series, the lowest sustained FPS, the worst single hitch,
284
+ * and an approximate dropped-frame count. Long tasks (>50ms) show up here as
285
+ * big gaps, but so does sustained scroll jank (30–50ms frames) that the
286
+ * long-task collector's threshold misses.
287
+ *
288
+ * Returns null when there are too few frames to analyze.
289
+ */
290
+ declare function analyzeFrames(frames: number[]): FrameStats | null;
291
+ interface FrameCollector extends Collector {
292
+ /** Attach the captured frame timestamps to the result. Returns the input
293
+ * untouched when too few frames were seen (or rAF was unavailable). */
294
+ finalize(result: RecordingResult): RecordingResult;
295
+ }
296
+ /**
297
+ * Records requestAnimationFrame timestamps for the duration of a recording so
298
+ * the UI can chart frame rate and surface jank. Emits no signals — the
299
+ * timestamps are a side track attached at finalize. No-ops when
300
+ * requestAnimationFrame is unavailable (non-DOM environments).
301
+ */
302
+ declare function createFrameCollector(): FrameCollector;
303
+
304
+ /**
305
+ * JS Self-Profiling API trace shape (the subset we consume).
306
+ * @see https://wicg.github.io/js-self-profiling/
307
+ */
308
+ interface ProfilerFrame {
309
+ name?: string;
310
+ resourceId?: number;
311
+ line?: number;
312
+ column?: number;
313
+ }
314
+ interface ProfilerStack {
315
+ frameId: number;
316
+ parentId?: number;
317
+ }
318
+ interface ProfilerSample {
319
+ /** DOMHighResTimeStamp, same clock as performance.now() / entry.startTime. */
320
+ timestamp: number;
321
+ stackId?: number;
322
+ }
323
+ interface ProfilerTrace {
324
+ resources: string[];
325
+ frames: ProfilerFrame[];
326
+ stacks: ProfilerStack[];
327
+ samples: ProfilerSample[];
328
+ }
329
+ /**
330
+ * Whether a resource URL belongs to the developer's own source (vs. a
331
+ * dependency or tooling shim). Dependency frames are noise when the question
332
+ * is "which of MY functions is slow".
333
+ */
334
+ declare function isUserResource(url: string | undefined): boolean;
335
+ /**
336
+ * Aggregate the hottest user-source frames among samples whose timestamp
337
+ * falls in [start, end]. `selfRatio` is relative to ALL in-window samples
338
+ * (including vendor-only ones), so it reflects the share of the task's
339
+ * blocking time spent in that user function.
340
+ */
341
+ declare function attributeWindow(trace: ProfilerTrace, start: number, end: number): LongTaskAttribution[];
342
+ /**
343
+ * Return a copy of `signals` with each long-task signal enriched with the
344
+ * hottest user frames sampled during its window. Long tasks with no in-window
345
+ * user samples are left without `attribution`. Non-long-task signals pass
346
+ * through by reference.
347
+ */
348
+ declare function attributeLongTaskSignals(trace: ProfilerTrace, signals: Signal[]): Signal[];
349
+ interface SelfProfilingCollector extends Collector {
350
+ /** Stop the profiler (if running), await its trace, and return a result
351
+ * with long-task signals enriched. Falls back to the input on any failure
352
+ * or when the Profiler API / Document-Policy header is unavailable. */
353
+ finalize(result: RecordingResult): Promise<RecordingResult>;
354
+ }
355
+ /**
356
+ * Collector that runs the JS Self-Profiling API across a recording and, on
357
+ * finalize, attributes each long task to the developer's own hot functions —
358
+ * something LoAF can't do for React-delegated events (it only sees React's
359
+ * single root dispatcher).
360
+ *
361
+ * It reports `kind: 'long-task'` because it enriches that signal rather than
362
+ * emitting its own. Requires Chromium + a `Document-Policy: js-profiling`
363
+ * response header (the dev plugins inject it); degrades to a no-op otherwise.
364
+ */
365
+ declare function createSelfProfilingCollector(): SelfProfilingCollector;
366
+
367
+ export { type Collector, type CollectorKind, type CreateSourceMapResolverOptions, type FetchMap, type ForcedReflowSignal, type FpsSample, type FrameCollector, type FrameStats, type HeapCollector, type HeapSample, type HeapTrend, type HeapTrendClass, type InteractionCollector, type InteractionSignal, type LayoutShiftSignal, 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, createForcedReflowCollector, createFrameCollector, createHeapCollector, createInteractionCollector, createLayoutShiftCollector, createLongTasksCollector, createNetworkCollector, createRecorder, createSelfProfilingCollector, createSourceMapResolver, createWebVitalsCollector, isUserResource, parseStack, resolveFrame };