react-render-detective 0.2.0 → 0.4.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/README.md +1 -1
- package/dist/babel.cjs +2 -2
- package/dist/babel.js +1 -1
- package/dist/{chunk-6YPJQ7M5.cjs → chunk-DZ3BZ654.cjs} +88 -4
- package/dist/chunk-DZ3BZ654.cjs.map +1 -0
- package/dist/chunk-H5RG2EPP.cjs +75 -0
- package/dist/chunk-H5RG2EPP.cjs.map +1 -0
- package/dist/{chunk-LSIXHWQD.js → chunk-HOTY7J3X.js} +14 -4
- package/dist/chunk-HOTY7J3X.js.map +1 -0
- package/dist/{chunk-QQ4VARSD.cjs → chunk-JD4IJ4MN.cjs} +14 -4
- package/dist/chunk-JD4IJ4MN.cjs.map +1 -0
- package/dist/chunk-LILK23YH.js +71 -0
- package/dist/chunk-LILK23YH.js.map +1 -0
- package/dist/{chunk-PSCFSJ65.js → chunk-MCIQMYNR.js} +12 -5
- package/dist/chunk-MCIQMYNR.js.map +1 -0
- package/dist/{chunk-BPNLNIHU.cjs → chunk-PHYYA67T.cjs} +12 -5
- package/dist/chunk-PHYYA67T.cjs.map +1 -0
- package/dist/{chunk-MSISKPST.js → chunk-QOGTEVBP.js} +88 -4
- package/dist/chunk-QOGTEVBP.js.map +1 -0
- package/dist/core.cjs +18 -18
- package/dist/core.d.cts +38 -2
- package/dist/core.d.ts +38 -2
- package/dist/core.js +1 -1
- package/dist/index.cjs +354 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +172 -4
- package/dist/index.d.ts +172 -4
- package/dist/index.js +316 -6
- package/dist/index.js.map +1 -1
- package/dist/overlay.cjs +6 -6
- package/dist/overlay.cjs.map +1 -1
- package/dist/overlay.js +4 -4
- package/dist/overlay.js.map +1 -1
- package/dist/testing.cjs +20 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +62 -0
- package/dist/testing.d.ts +62 -0
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -0
- package/dist/{types-BWVaQPHb.d.cts → types-gl13xwmN.d.cts} +5 -0
- package/dist/{types-BWVaQPHb.d.ts → types-gl13xwmN.d.ts} +5 -0
- package/dist/vite.cjs +9 -6
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.js +8 -5
- package/dist/vite.js.map +1 -1
- package/package.json +6 -1
- package/dist/chunk-6YPJQ7M5.cjs.map +0 -1
- package/dist/chunk-BPNLNIHU.cjs.map +0 -1
- package/dist/chunk-LSIXHWQD.js.map +0 -1
- package/dist/chunk-MSISKPST.js.map +0 -1
- package/dist/chunk-PSCFSJ65.js.map +0 -1
- package/dist/chunk-QQ4VARSD.cjs.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,130 @@
|
|
|
1
|
-
import { b as RenderEvent,
|
|
2
|
-
export { i as ComponentInfo, C as ContextChange, f as Diagnosis, I as Inspected, g as InspectionLimits, M as Mode, P as PropChange, k as PropChangeKind, h as PropValueType, c as RenderPhase, l as RenderTimings, e as Thresholds, T as TrackedStateChange } from './types-
|
|
1
|
+
import { b as RenderEvent, j as Confidence, R as RenderReason, a as DetectiveOptions, D as DetectiveConfig, A as AppStats, d as ComponentStats } from './types-gl13xwmN.js';
|
|
2
|
+
export { i as ComponentInfo, C as ContextChange, f as Diagnosis, I as Inspected, g as InspectionLimits, M as Mode, P as PropChange, k as PropChangeKind, h as PropValueType, c as RenderPhase, l as RenderTimings, e as Thresholds, T as TrackedStateChange } from './types-gl13xwmN.js';
|
|
3
|
+
import { RenderProfile } from './testing.js';
|
|
3
4
|
import { ComponentType, ReactNode, Dispatch, SetStateAction } from 'react';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Interaction-scoped attribution.
|
|
8
|
+
*
|
|
9
|
+
* Performance is felt per interaction, not in aggregate — and INP is the metric
|
|
10
|
+
* teams are actually judged on. This joins the two halves: the browser says a
|
|
11
|
+
* keystroke took 240ms, and the render events say which components spent it and
|
|
12
|
+
* why.
|
|
13
|
+
*
|
|
14
|
+
* Uses `PerformanceObserver` with `event` timing, a public browser API. Where it
|
|
15
|
+
* is unsupported (older Safari, jsdom) interaction tracking simply stays empty
|
|
16
|
+
* rather than guessing.
|
|
17
|
+
*/
|
|
18
|
+
interface InteractionRecord {
|
|
19
|
+
id: string;
|
|
20
|
+
/** `click`, `keydown`, `pointerup`… */
|
|
21
|
+
type: string;
|
|
22
|
+
/** Best-effort description of what was interacted with. */
|
|
23
|
+
target?: string;
|
|
24
|
+
startTime: number;
|
|
25
|
+
/** Browser-reported event duration — the number INP is computed from. */
|
|
26
|
+
durationMs: number;
|
|
27
|
+
/**
|
|
28
|
+
* For a manually measured interaction: how long the synchronous action took.
|
|
29
|
+
* Immune to throttling, unlike the full window, which waits on a frame.
|
|
30
|
+
*/
|
|
31
|
+
handlerMs?: number;
|
|
32
|
+
/** Renders committed inside this interaction's window. */
|
|
33
|
+
renders: RenderEvent[];
|
|
34
|
+
/** Sum of self durations for those renders. */
|
|
35
|
+
renderTimeMs: number;
|
|
36
|
+
/** Render time that no observable input change explains. */
|
|
37
|
+
avoidableRenderTimeMs: number;
|
|
38
|
+
}
|
|
39
|
+
interface InteractionSummary {
|
|
40
|
+
interaction: InteractionRecord;
|
|
41
|
+
/** Components ordered by cost within this interaction. */
|
|
42
|
+
contributors: Array<{
|
|
43
|
+
component: string;
|
|
44
|
+
source?: string;
|
|
45
|
+
renders: number;
|
|
46
|
+
totalMs: number;
|
|
47
|
+
cause: string;
|
|
48
|
+
}>;
|
|
49
|
+
headline: string;
|
|
50
|
+
nextStep: string;
|
|
51
|
+
confidence: Confidence;
|
|
52
|
+
}
|
|
53
|
+
interface RawEventTiming {
|
|
54
|
+
name: string;
|
|
55
|
+
startTime: number;
|
|
56
|
+
duration: number;
|
|
57
|
+
target?: string;
|
|
58
|
+
handlerMs?: number;
|
|
59
|
+
}
|
|
60
|
+
declare class InteractionTracker {
|
|
61
|
+
private capacity;
|
|
62
|
+
private records;
|
|
63
|
+
private observer;
|
|
64
|
+
private nextId;
|
|
65
|
+
constructor(capacity?: number);
|
|
66
|
+
/** Returns false when the browser cannot report event timing. */
|
|
67
|
+
start(): boolean;
|
|
68
|
+
stop(): void;
|
|
69
|
+
/** Is the automatic path available in this browser? */
|
|
70
|
+
get automatic(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Time an interaction by hand.
|
|
73
|
+
*
|
|
74
|
+
* The automatic path depends on the Event Timing API, which Safari only
|
|
75
|
+
* gained in 16.4 and which does not fire for synthetic input at all — so
|
|
76
|
+
* anything driven by a test harness records nothing. This measures a specific
|
|
77
|
+
* action instead, up to the paint that follows it, and needs no browser
|
|
78
|
+
* support beyond `performance.now`.
|
|
79
|
+
*/
|
|
80
|
+
measure<T>(label: string, action: () => T): T;
|
|
81
|
+
/** Exposed for tests and for `measure`. */
|
|
82
|
+
record(timing: RawEventTiming): InteractionRecord;
|
|
83
|
+
clear(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Joins render events to interactions by commit time. A render belongs to an
|
|
86
|
+
* interaction when it committed between the event starting and shortly after
|
|
87
|
+
* it finished — React commits just after the event handler returns.
|
|
88
|
+
*/
|
|
89
|
+
attribute(events: RenderEvent[]): InteractionRecord[];
|
|
90
|
+
}
|
|
91
|
+
declare function summarise(record: InteractionRecord): InteractionSummary;
|
|
92
|
+
declare function formatInteraction(summary: InteractionSummary): string;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* "Where should I spend my next hour?"
|
|
96
|
+
*
|
|
97
|
+
* Render counts answer the wrong question — a component rendering 2 000 times
|
|
98
|
+
* for 0.01ms is not the problem, and one rendering 40 times for 12ms might be.
|
|
99
|
+
* This ranks by **estimated recoverable time**, so the top of the list is the
|
|
100
|
+
* biggest win rather than the noisiest component.
|
|
101
|
+
*/
|
|
102
|
+
interface Opportunity {
|
|
103
|
+
component: string;
|
|
104
|
+
source?: string;
|
|
105
|
+
/** Milliseconds plausibly recovered by fixing this. The ranking key. */
|
|
106
|
+
estimatedSavingMs: number;
|
|
107
|
+
/** Renders where no observable input changed. */
|
|
108
|
+
avoidableRenders: number;
|
|
109
|
+
/** Times the component was rebuilt rather than re-rendered. */
|
|
110
|
+
remounts: number;
|
|
111
|
+
averageSelfDuration: number;
|
|
112
|
+
/** One line: what to look at. Comes from the diagnostic engine, not from here. */
|
|
113
|
+
summary: string;
|
|
114
|
+
nextStep: string;
|
|
115
|
+
confidence: Confidence;
|
|
116
|
+
}
|
|
117
|
+
interface OpportunityInput {
|
|
118
|
+
events: RenderEvent[];
|
|
119
|
+
lifecycles: Map<string, {
|
|
120
|
+
remounts: number;
|
|
121
|
+
}>;
|
|
122
|
+
/** Ignore anything below this. Noise is worse than silence in a ranked list. */
|
|
123
|
+
minSavingMs?: number;
|
|
124
|
+
}
|
|
125
|
+
declare function rankOpportunities({ events, lifecycles, minSavingMs }: OpportunityInput): Opportunity[];
|
|
126
|
+
declare function formatOpportunities(opportunities: Opportunity[]): string;
|
|
127
|
+
|
|
5
128
|
interface TrackOptions {
|
|
6
129
|
/** Overrides the inferred component name. Required for anonymous components. */
|
|
7
130
|
name?: string;
|
|
@@ -10,6 +133,12 @@ interface TrackOptions {
|
|
|
10
133
|
* is no runtime way to obtain it.
|
|
11
134
|
*/
|
|
12
135
|
source?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Set by the build plugin when the component is declared inside another
|
|
138
|
+
* function. React sees a new component type on every parent render and
|
|
139
|
+
* rebuilds the whole subtree, so this turns a guess into a fact.
|
|
140
|
+
*/
|
|
141
|
+
declaredInRender?: boolean;
|
|
13
142
|
}
|
|
14
143
|
|
|
15
144
|
/**
|
|
@@ -107,6 +236,8 @@ interface Explanation {
|
|
|
107
236
|
potentiallyAvoidableRenders: number;
|
|
108
237
|
estimatedAvoidableTime: number;
|
|
109
238
|
devReplays: number;
|
|
239
|
+
/** Times the component was rebuilt rather than re-rendered. */
|
|
240
|
+
remounts: number;
|
|
110
241
|
headline: string;
|
|
111
242
|
nextStep: string;
|
|
112
243
|
confidence: Confidence;
|
|
@@ -115,7 +246,9 @@ interface Explanation {
|
|
|
115
246
|
* The flagship "explain this render" answer, aggregated across the recorded
|
|
116
247
|
* history of one component. Pure — no React, no console.
|
|
117
248
|
*/
|
|
118
|
-
declare function explainEvents(component: string, events: RenderEvent[]
|
|
249
|
+
declare function explainEvents(component: string, events: RenderEvent[], lifecycle?: {
|
|
250
|
+
remounts: number;
|
|
251
|
+
}): Explanation | undefined;
|
|
119
252
|
declare function formatExplanation(e: Explanation): string;
|
|
120
253
|
|
|
121
254
|
/**
|
|
@@ -145,6 +278,33 @@ declare function reset(): void;
|
|
|
145
278
|
declare function explain(componentName: string): string | undefined;
|
|
146
279
|
/** Structured form of `explain`, for building UIs on top. */
|
|
147
280
|
declare function explainStructured(componentName: string): Explanation | undefined;
|
|
281
|
+
/** Snapshot render behaviour for regression testing. See `react-render-detective/testing`. */
|
|
282
|
+
declare function getRenderProfile(scenario: string): RenderProfile;
|
|
283
|
+
/**
|
|
284
|
+
* Interactions, slowest first, with the renders that happened inside each.
|
|
285
|
+
*
|
|
286
|
+
* This is the bridge from render causality to what a user actually feels: the
|
|
287
|
+
* browser reports how long the interaction took, and the render events say
|
|
288
|
+
* which components spent that time and why.
|
|
289
|
+
*/
|
|
290
|
+
declare function getInteractions(): InteractionRecord[];
|
|
291
|
+
/** Structured analysis of one interaction. Defaults to the slowest recorded. */
|
|
292
|
+
declare function explainInteractionStructured(id?: string): InteractionSummary | undefined;
|
|
293
|
+
declare function explainInteraction(id?: string): string | undefined;
|
|
294
|
+
declare function printInteractions(limit?: number): void;
|
|
295
|
+
/**
|
|
296
|
+
* Time one interaction explicitly, up to the paint that follows it.
|
|
297
|
+
*
|
|
298
|
+
* Needed wherever the automatic path cannot see: Safari before 16.4, and any
|
|
299
|
+
* synthetic input, which never produces Event Timing entries.
|
|
300
|
+
*/
|
|
301
|
+
declare function measureInteraction<T>(label: string, action: () => T): T;
|
|
302
|
+
/**
|
|
303
|
+
* Components ranked by estimated recoverable time — the triage view. Render
|
|
304
|
+
* counts answer the wrong question; this answers "what should I fix first?".
|
|
305
|
+
*/
|
|
306
|
+
declare function getOpportunities(limit?: number): Opportunity[];
|
|
307
|
+
declare function printOpportunities(limit?: number): void;
|
|
148
308
|
/** Prints the application-level dashboard (§53). */
|
|
149
309
|
declare function printStats(): void;
|
|
150
310
|
/** Namespaced form, matching the documented `ReactRenderDetective.init()` usage. */
|
|
@@ -161,7 +321,15 @@ declare const ReactRenderDetective: {
|
|
|
161
321
|
reset: typeof reset;
|
|
162
322
|
explain: typeof explain;
|
|
163
323
|
explainStructured: typeof explainStructured;
|
|
324
|
+
getOpportunities: typeof getOpportunities;
|
|
325
|
+
printOpportunities: typeof printOpportunities;
|
|
326
|
+
getInteractions: typeof getInteractions;
|
|
327
|
+
explainInteraction: typeof explainInteraction;
|
|
328
|
+
explainInteractionStructured: typeof explainInteractionStructured;
|
|
329
|
+
printInteractions: typeof printInteractions;
|
|
330
|
+
measureInteraction: typeof measureInteraction;
|
|
331
|
+
getRenderProfile: typeof getRenderProfile;
|
|
164
332
|
printStats: typeof printStats;
|
|
165
333
|
};
|
|
166
334
|
|
|
167
|
-
export { AppStats, ComponentStats, Confidence, DetectiveConfig, DetectiveOptions, type Explanation, ReactRenderDetective, RenderDetective, type RenderDetectiveProps, RenderEvent, RenderReason, type TrackOptions, clear, configure, explain, explainEvents, explainStructured, formatExplanation, getComponentStats, getConfig, getEvents, getStats, init, isEnabled, printStats, reset, subscribe, useRenderDiagnostics, useTrackedContextValue, useTrackedEffect, useTrackedState, withRenderDetective };
|
|
335
|
+
export { AppStats, ComponentStats, Confidence, DetectiveConfig, DetectiveOptions, type Explanation, type InteractionRecord, type InteractionSummary, InteractionTracker, type Opportunity, ReactRenderDetective, RenderDetective, type RenderDetectiveProps, RenderEvent, RenderReason, type TrackOptions, clear, configure, explain, explainEvents, explainInteraction, explainInteractionStructured, explainStructured, formatExplanation, formatInteraction, formatOpportunities, getComponentStats, getConfig, getEvents, getInteractions, getOpportunities, getRenderProfile, getStats, init, isEnabled, measureInteraction, printInteractions, printOpportunities, printStats, rankOpportunities, reset, subscribe, summarise as summariseInteraction, useRenderDiagnostics, useTrackedContextValue, useTrackedEffect, useTrackedState, withRenderDetective };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { explainEvents, formatExplanation } from './chunk-
|
|
2
|
-
export { explainEvents, formatExplanation } from './chunk-
|
|
3
|
-
import { getDetective, inspect, shallowEqual, diffProps, formatInspected } from './chunk-
|
|
1
|
+
import { explainEvents, formatExplanation } from './chunk-HOTY7J3X.js';
|
|
2
|
+
export { explainEvents, formatExplanation } from './chunk-HOTY7J3X.js';
|
|
3
|
+
import { getDetective, inspect, shallowEqual, diffProps, formatInspected } from './chunk-QOGTEVBP.js';
|
|
4
|
+
import { profileFromEvents } from './chunk-LILK23YH.js';
|
|
4
5
|
import * as React from 'react';
|
|
5
6
|
import { createContext, useContext, useState, useRef, useEffect, useLayoutEffect, useCallback, Profiler } from 'react';
|
|
6
7
|
import { jsx } from 'react/jsx-runtime';
|
|
@@ -118,6 +119,245 @@ function label(event) {
|
|
|
118
119
|
return "undetermined";
|
|
119
120
|
}
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
// src/core/interactions.ts
|
|
124
|
+
var COMMIT_SLACK_MS = 100;
|
|
125
|
+
var FALLBACK_CLOSE_MS = 50;
|
|
126
|
+
var InteractionTracker = class {
|
|
127
|
+
constructor(capacity = 50) {
|
|
128
|
+
this.capacity = capacity;
|
|
129
|
+
this.records = [];
|
|
130
|
+
this.nextId = 0;
|
|
131
|
+
}
|
|
132
|
+
/** Returns false when the browser cannot report event timing. */
|
|
133
|
+
start() {
|
|
134
|
+
if (this.observer) return true;
|
|
135
|
+
const PO = globalThis.PerformanceObserver;
|
|
136
|
+
const supported = PO?.supportedEntryTypes?.includes("event");
|
|
137
|
+
if (!PO || !supported) return false;
|
|
138
|
+
try {
|
|
139
|
+
const observer = new PO((list) => {
|
|
140
|
+
for (const entry of list.getEntries()) {
|
|
141
|
+
const timing = entry;
|
|
142
|
+
this.record({
|
|
143
|
+
name: timing.name,
|
|
144
|
+
startTime: timing.startTime,
|
|
145
|
+
duration: timing.duration,
|
|
146
|
+
target: describeTarget(timing.target)
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
observer.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
151
|
+
this.observer = observer;
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
stop() {
|
|
158
|
+
this.observer?.disconnect();
|
|
159
|
+
this.observer = void 0;
|
|
160
|
+
}
|
|
161
|
+
/** Is the automatic path available in this browser? */
|
|
162
|
+
get automatic() {
|
|
163
|
+
return this.observer !== void 0;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Time an interaction by hand.
|
|
167
|
+
*
|
|
168
|
+
* The automatic path depends on the Event Timing API, which Safari only
|
|
169
|
+
* gained in 16.4 and which does not fire for synthetic input at all — so
|
|
170
|
+
* anything driven by a test harness records nothing. This measures a specific
|
|
171
|
+
* action instead, up to the paint that follows it, and needs no browser
|
|
172
|
+
* support beyond `performance.now`.
|
|
173
|
+
*/
|
|
174
|
+
measure(label2, action) {
|
|
175
|
+
const startTime = now();
|
|
176
|
+
let handlerMs = 0;
|
|
177
|
+
let finished = false;
|
|
178
|
+
const finish = () => {
|
|
179
|
+
if (finished) return;
|
|
180
|
+
finished = true;
|
|
181
|
+
this.record({ name: label2, startTime, duration: now() - startTime, handlerMs });
|
|
182
|
+
};
|
|
183
|
+
let result;
|
|
184
|
+
try {
|
|
185
|
+
result = action();
|
|
186
|
+
handlerMs = now() - startTime;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
handlerMs = now() - startTime;
|
|
189
|
+
finish();
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
const raf = globalThis.requestAnimationFrame;
|
|
193
|
+
if (raf) raf(() => raf(finish));
|
|
194
|
+
setTimeout(finish, FALLBACK_CLOSE_MS);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
/** Exposed for tests and for `measure`. */
|
|
198
|
+
record(timing) {
|
|
199
|
+
const record = {
|
|
200
|
+
id: `interaction_${++this.nextId}`,
|
|
201
|
+
type: timing.name,
|
|
202
|
+
target: timing.target,
|
|
203
|
+
startTime: timing.startTime,
|
|
204
|
+
durationMs: timing.duration,
|
|
205
|
+
handlerMs: timing.handlerMs,
|
|
206
|
+
renders: [],
|
|
207
|
+
renderTimeMs: 0,
|
|
208
|
+
avoidableRenderTimeMs: 0
|
|
209
|
+
};
|
|
210
|
+
this.records.push(record);
|
|
211
|
+
if (this.records.length > this.capacity) this.records.shift();
|
|
212
|
+
return record;
|
|
213
|
+
}
|
|
214
|
+
clear() {
|
|
215
|
+
this.records.length = 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Joins render events to interactions by commit time. A render belongs to an
|
|
219
|
+
* interaction when it committed between the event starting and shortly after
|
|
220
|
+
* it finished — React commits just after the event handler returns.
|
|
221
|
+
*/
|
|
222
|
+
attribute(events) {
|
|
223
|
+
for (const record of this.records) {
|
|
224
|
+
const from = record.startTime;
|
|
225
|
+
const to = record.startTime + record.durationMs + COMMIT_SLACK_MS;
|
|
226
|
+
record.renders = events.filter((e) => e.timings.commitTime >= from && e.timings.commitTime <= to);
|
|
227
|
+
record.renderTimeMs = record.renders.reduce((a, e) => a + e.timings.selfDuration, 0);
|
|
228
|
+
record.avoidableRenderTimeMs = record.renders.filter((e) => e.diagnosis.potentiallyAvoidable).reduce((a, e) => a + e.timings.selfDuration, 0);
|
|
229
|
+
}
|
|
230
|
+
return [...this.records].sort((a, b) => b.durationMs - a.durationMs);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
function summarise(record) {
|
|
234
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
235
|
+
for (const event of record.renders) {
|
|
236
|
+
const entry = byComponent.get(event.component.name) ?? {
|
|
237
|
+
renders: 0,
|
|
238
|
+
totalMs: 0,
|
|
239
|
+
source: event.component.source,
|
|
240
|
+
cause: event.diagnosis.reason
|
|
241
|
+
};
|
|
242
|
+
entry.renders++;
|
|
243
|
+
entry.totalMs += event.timings.selfDuration;
|
|
244
|
+
byComponent.set(event.component.name, entry);
|
|
245
|
+
}
|
|
246
|
+
const contributors = [...byComponent.entries()].map(([component, v]) => ({ component, source: v.source, renders: v.renders, totalMs: v.totalMs, cause: v.cause })).sort((a, b) => b.totalMs - a.totalMs);
|
|
247
|
+
const top = contributors[0];
|
|
248
|
+
const accounted = (record.handlerMs ?? record.durationMs) + record.renderTimeMs;
|
|
249
|
+
const idleWindow = record.handlerMs !== void 0 && record.durationMs > accounted * 3 && record.durationMs - accounted > 100;
|
|
250
|
+
const effectiveMs = idleWindow ? accounted : record.durationMs;
|
|
251
|
+
const share = effectiveMs > 0 ? record.renderTimeMs / effectiveMs : 0;
|
|
252
|
+
let headline;
|
|
253
|
+
let nextStep;
|
|
254
|
+
let confidence = "medium";
|
|
255
|
+
if (idleWindow) {
|
|
256
|
+
headline = `${record.type}: ${fmt(record.handlerMs ?? 0)} in the handler and ${fmt(record.renderTimeMs)} rendering. The measured window was ${fmt(record.durationMs)}, but most of that was the page waiting for a frame \u2014 ignore it.`;
|
|
257
|
+
nextStep = record.renderTimeMs > (record.handlerMs ?? 0) ? `Rendering dominates the real work${top ? `; start with ${top.component}` : ""}.` : "The handler itself costs more than rendering. Profile the handler, not React.";
|
|
258
|
+
return { interaction: record, contributors, headline, nextStep, confidence: "medium" };
|
|
259
|
+
}
|
|
260
|
+
if (record.renders.length === 0) {
|
|
261
|
+
headline = `${record.type} took ${fmt(record.durationMs)}, and no instrumented component rendered inside it.`;
|
|
262
|
+
nextStep = "The cost is somewhere other than React rendering \u2014 an event handler, a layout, or an uninstrumented component. Instrument more of the tree to narrow it down.";
|
|
263
|
+
confidence = "low";
|
|
264
|
+
} else if (share >= 0.4 && record.avoidableRenderTimeMs > 0) {
|
|
265
|
+
headline = `${record.type} took ${fmt(record.durationMs)}; ${fmt(record.renderTimeMs)} of it was rendering, and ${fmt(record.avoidableRenderTimeMs)} of that had no input change to explain it.`;
|
|
266
|
+
nextStep = top ? `Start with ${top.component}${top.source ? ` (${top.source})` : ""} \u2014 ${fmt(top.totalMs)} across ${top.renders} render${top.renders === 1 ? "" : "s"}.` : "Look at the top contributor below.";
|
|
267
|
+
confidence = "high";
|
|
268
|
+
} else if (share >= 0.4) {
|
|
269
|
+
headline = `${record.type} took ${fmt(record.durationMs)}; ${fmt(record.renderTimeMs)} of it was rendering, all of it explained by real input changes.`;
|
|
270
|
+
nextStep = "This is genuine work. Make the renders cheaper rather than fewer \u2014 or do less of it per interaction.";
|
|
271
|
+
confidence = "high";
|
|
272
|
+
} else {
|
|
273
|
+
headline = `${record.type} took ${fmt(record.durationMs)}, but only ${fmt(record.renderTimeMs)} was React rendering.`;
|
|
274
|
+
nextStep = "Most of the cost is outside rendering \u2014 event handlers, layout or paint. A browser profile will show more than this tool can.";
|
|
275
|
+
confidence = "medium";
|
|
276
|
+
}
|
|
277
|
+
return { interaction: record, contributors, headline, nextStep, confidence };
|
|
278
|
+
}
|
|
279
|
+
function formatInteraction(summary) {
|
|
280
|
+
const { interaction: i } = summary;
|
|
281
|
+
const lines = [
|
|
282
|
+
`${i.type}${i.target ? ` on ${i.target}` : ""} ${fmt(i.durationMs)}`,
|
|
283
|
+
"",
|
|
284
|
+
summary.headline
|
|
285
|
+
];
|
|
286
|
+
if (summary.contributors.length > 0) {
|
|
287
|
+
lines.push("", "Rendering inside this interaction");
|
|
288
|
+
for (const c of summary.contributors.slice(0, 8)) {
|
|
289
|
+
lines.push(
|
|
290
|
+
` ${c.component.padEnd(22)} ${String(c.renders).padStart(4)} render(s) ${fmt(c.totalMs).padStart(8)} ${c.cause}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
lines.push("", `Next step`, ` ${summary.nextStep}`, "", `Confidence: ${summary.confidence}`);
|
|
295
|
+
return lines.join("\n");
|
|
296
|
+
}
|
|
297
|
+
function describeTarget(target) {
|
|
298
|
+
if (!target || typeof target !== "object") return void 0;
|
|
299
|
+
const el = target;
|
|
300
|
+
if (!el.tagName) return void 0;
|
|
301
|
+
const tag = el.tagName.toLowerCase();
|
|
302
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
303
|
+
const className = typeof el.className === "string" ? el.className.trim().split(/\s+/)[0] : void 0;
|
|
304
|
+
if (className) return `${tag}.${className}`;
|
|
305
|
+
const text = el.textContent?.trim().slice(0, 20);
|
|
306
|
+
return text ? `${tag} "${text}"` : tag;
|
|
307
|
+
}
|
|
308
|
+
var fmt = (ms) => `${ms.toFixed(1)}ms`;
|
|
309
|
+
var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
310
|
+
|
|
311
|
+
// src/core/opportunities.ts
|
|
312
|
+
var DEFAULT_MIN_SAVING_MS = 1;
|
|
313
|
+
function rankOpportunities({ events, lifecycles, minSavingMs = DEFAULT_MIN_SAVING_MS }) {
|
|
314
|
+
const names = new Set(events.map((e) => e.component.name));
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const name of names) {
|
|
317
|
+
const lifecycle = lifecycles.get(name);
|
|
318
|
+
const explanation = explainEvents(name, events, lifecycle);
|
|
319
|
+
if (!explanation) continue;
|
|
320
|
+
const mounts = events.filter((e) => e.component.name === name && e.phase === "mount");
|
|
321
|
+
const averageMountCost = mounts.length ? mounts.reduce((a, e) => a + e.timings.selfDuration, 0) / mounts.length : 0;
|
|
322
|
+
const remountSaving = explanation.remounts * averageMountCost;
|
|
323
|
+
const estimatedSavingMs = explanation.estimatedAvoidableTime + remountSaving;
|
|
324
|
+
if (estimatedSavingMs < minSavingMs) continue;
|
|
325
|
+
out.push({
|
|
326
|
+
component: name,
|
|
327
|
+
source: explanation.source,
|
|
328
|
+
estimatedSavingMs,
|
|
329
|
+
avoidableRenders: explanation.potentiallyAvoidableRenders,
|
|
330
|
+
remounts: explanation.remounts,
|
|
331
|
+
averageSelfDuration: explanation.averageSelfDuration,
|
|
332
|
+
summary: explanation.headline,
|
|
333
|
+
nextStep: explanation.nextStep,
|
|
334
|
+
confidence: explanation.confidence
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return out.sort((a, b) => b.estimatedSavingMs - a.estimatedSavingMs);
|
|
338
|
+
}
|
|
339
|
+
function formatOpportunities(opportunities) {
|
|
340
|
+
if (opportunities.length === 0) {
|
|
341
|
+
return "React Render Detective\n\nNo measurable render waste found yet. Interact with the app and try again.";
|
|
342
|
+
}
|
|
343
|
+
const lines = [
|
|
344
|
+
"React Render Detective \u2014 where to spend your next hour",
|
|
345
|
+
"",
|
|
346
|
+
"Ranked by estimated recoverable time. These are estimates, not promises:",
|
|
347
|
+
"measure each fix.",
|
|
348
|
+
""
|
|
349
|
+
];
|
|
350
|
+
for (const [index, o] of opportunities.entries()) {
|
|
351
|
+
lines.push(
|
|
352
|
+
`${String(index + 1).padStart(2)}. ${o.component}${o.source ? ` ${o.source}` : ""}`,
|
|
353
|
+
` ~${o.estimatedSavingMs.toFixed(0)}ms recoverable ${o.avoidableRenders} avoidable render${o.avoidableRenders === 1 ? "" : "s"}${o.remounts > 0 ? `, ${o.remounts}\xD7 rebuilt` : ""} (confidence: ${o.confidence})`,
|
|
354
|
+
` ${o.summary}`,
|
|
355
|
+
` \u2192 ${o.nextStep}`,
|
|
356
|
+
""
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
121
361
|
var AncestryContext = createContext(void 0);
|
|
122
362
|
AncestryContext.displayName = "RenderDetectiveAncestry";
|
|
123
363
|
function useInstrumentedNode(name, props, source) {
|
|
@@ -171,6 +411,7 @@ var IS_WRAPPER = /* @__PURE__ */ Symbol.for("react-render-detective.wrapper");
|
|
|
171
411
|
function withRenderDetective(Component, options = {}) {
|
|
172
412
|
if (Component[IS_WRAPPER]) return Component;
|
|
173
413
|
const name = options.name ?? componentName(Component);
|
|
414
|
+
getDetective().noteDefinition(name, options.source, options.declaredInRender === true);
|
|
174
415
|
function RenderDetected(props) {
|
|
175
416
|
const { node, onRender } = useInstrumentedNode(name, props, options.source);
|
|
176
417
|
return renderInstrumented(node, onRender, /* @__PURE__ */ jsx(Component, { ...props }));
|
|
@@ -284,12 +525,21 @@ function isRecord(v) {
|
|
|
284
525
|
|
|
285
526
|
// src/index.ts
|
|
286
527
|
var REPORTER = /* @__PURE__ */ Symbol.for("react-render-detective.reporter");
|
|
528
|
+
var INTERACTIONS = /* @__PURE__ */ Symbol.for("react-render-detective.interactions");
|
|
529
|
+
function tracker() {
|
|
530
|
+
const g = globalThis;
|
|
531
|
+
if (!g[INTERACTIONS]) g[INTERACTIONS] = new InteractionTracker();
|
|
532
|
+
return g[INTERACTIONS];
|
|
533
|
+
}
|
|
287
534
|
function init(options = {}) {
|
|
288
535
|
const detective = getDetective();
|
|
289
536
|
detective.init(options);
|
|
290
537
|
const g = globalThis;
|
|
291
538
|
g[REPORTER]?.();
|
|
292
539
|
g[REPORTER] = void 0;
|
|
540
|
+
if (detective.enabled) {
|
|
541
|
+
tracker().start();
|
|
542
|
+
}
|
|
293
543
|
if (detective.enabled && detective.config.mode !== "silent") {
|
|
294
544
|
g[REPORTER] = attachConsoleReporter(detective);
|
|
295
545
|
} else if (!detective.enabled) {
|
|
@@ -321,19 +571,63 @@ function subscribe(listener) {
|
|
|
321
571
|
}
|
|
322
572
|
function clear() {
|
|
323
573
|
getDetective().clear();
|
|
574
|
+
tracker().clear();
|
|
324
575
|
}
|
|
325
576
|
function reset() {
|
|
326
577
|
const g = globalThis;
|
|
327
578
|
g[REPORTER]?.();
|
|
328
579
|
g[REPORTER] = void 0;
|
|
580
|
+
g[INTERACTIONS]?.stop();
|
|
581
|
+
g[INTERACTIONS] = void 0;
|
|
329
582
|
getDetective().reset();
|
|
330
583
|
}
|
|
331
584
|
function explain(componentName2) {
|
|
332
|
-
const explanation =
|
|
585
|
+
const explanation = explainStructured(componentName2);
|
|
333
586
|
return explanation ? formatExplanation(explanation) : void 0;
|
|
334
587
|
}
|
|
335
588
|
function explainStructured(componentName2) {
|
|
336
|
-
return explainEvents(componentName2, getEvents());
|
|
589
|
+
return explainEvents(componentName2, getEvents(), getDetective().lifecycleOf(componentName2));
|
|
590
|
+
}
|
|
591
|
+
function getRenderProfile(scenario) {
|
|
592
|
+
const remounts = {};
|
|
593
|
+
for (const stats of getDetective().getComponentStats()) remounts[stats.name] = stats.remountCount;
|
|
594
|
+
return profileFromEvents(scenario, getEvents(), remounts);
|
|
595
|
+
}
|
|
596
|
+
function getInteractions() {
|
|
597
|
+
return tracker().attribute(getEvents());
|
|
598
|
+
}
|
|
599
|
+
function explainInteractionStructured(id) {
|
|
600
|
+
const records = getInteractions();
|
|
601
|
+
const record = id ? records.find((r) => r.id === id) : records[0];
|
|
602
|
+
return record ? summarise(record) : void 0;
|
|
603
|
+
}
|
|
604
|
+
function explainInteraction(id) {
|
|
605
|
+
const summary = explainInteractionStructured(id);
|
|
606
|
+
return summary ? formatInteraction(summary) : void 0;
|
|
607
|
+
}
|
|
608
|
+
function printInteractions(limit = 5) {
|
|
609
|
+
const records = getInteractions().slice(0, limit);
|
|
610
|
+
if (records.length === 0) {
|
|
611
|
+
console.log(
|
|
612
|
+
tracker().automatic ? "No interactions recorded yet. Event timing is working \u2014 nothing has taken longer than 16ms.\nSynthetic clicks from a test harness never produce these entries; use measureInteraction() there." : "This browser does not report event timing (Safari before 16.4, jsdom).\nUse measureInteraction(label, fn) to time interactions by hand."
|
|
613
|
+
);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
console.log(records.map((r) => formatInteraction(summarise(r))).join("\n\n"));
|
|
617
|
+
}
|
|
618
|
+
function measureInteraction(label2, action) {
|
|
619
|
+
return tracker().measure(label2, action);
|
|
620
|
+
}
|
|
621
|
+
function getOpportunities(limit = 10) {
|
|
622
|
+
const detective = getDetective();
|
|
623
|
+
const lifecycles = /* @__PURE__ */ new Map();
|
|
624
|
+
for (const stats of detective.getComponentStats()) {
|
|
625
|
+
lifecycles.set(stats.name, { remounts: stats.remountCount });
|
|
626
|
+
}
|
|
627
|
+
return rankOpportunities({ events: detective.getEvents(), lifecycles }).slice(0, limit);
|
|
628
|
+
}
|
|
629
|
+
function printOpportunities(limit = 10) {
|
|
630
|
+
console.log(formatOpportunities(getOpportunities(limit)));
|
|
337
631
|
}
|
|
338
632
|
function printStats() {
|
|
339
633
|
const s = getStats();
|
|
@@ -346,6 +640,14 @@ function printStats() {
|
|
|
346
640
|
`Slow renders ${s.slowRenders}`,
|
|
347
641
|
`Potentially avoidable ${s.potentiallyAvoidableRenders}`
|
|
348
642
|
];
|
|
643
|
+
const rebuilt = s.mostRendered.filter((c) => c.remountCount >= 2);
|
|
644
|
+
if (rebuilt.length > 0) {
|
|
645
|
+
lines.push(
|
|
646
|
+
"",
|
|
647
|
+
"Rebuilt rather than re-rendered (state and DOM discarded each time)",
|
|
648
|
+
...rebuilt.slice(0, 5).map((c) => ` ${c.name.padEnd(22)} ${String(c.remountCount).padStart(5)}\xD7 remounted`)
|
|
649
|
+
);
|
|
650
|
+
}
|
|
349
651
|
if (s.devReplays > 0) {
|
|
350
652
|
lines.push(`Development replays ${s.devReplays} (StrictMode / discarded \u2014 not counted above)`);
|
|
351
653
|
}
|
|
@@ -372,9 +674,17 @@ var ReactRenderDetective = {
|
|
|
372
674
|
reset,
|
|
373
675
|
explain,
|
|
374
676
|
explainStructured,
|
|
677
|
+
getOpportunities,
|
|
678
|
+
printOpportunities,
|
|
679
|
+
getInteractions,
|
|
680
|
+
explainInteraction,
|
|
681
|
+
explainInteractionStructured,
|
|
682
|
+
printInteractions,
|
|
683
|
+
measureInteraction,
|
|
684
|
+
getRenderProfile,
|
|
375
685
|
printStats
|
|
376
686
|
};
|
|
377
687
|
|
|
378
|
-
export { ReactRenderDetective, RenderDetective, clear, configure, explain, explainStructured, getComponentStats, getConfig, getEvents, getStats, init, isEnabled, printStats, reset, subscribe, useRenderDiagnostics, useTrackedContextValue, useTrackedEffect, useTrackedState, withRenderDetective };
|
|
688
|
+
export { InteractionTracker, ReactRenderDetective, RenderDetective, clear, configure, explain, explainInteraction, explainInteractionStructured, explainStructured, formatInteraction, formatOpportunities, getComponentStats, getConfig, getEvents, getInteractions, getOpportunities, getRenderProfile, getStats, init, isEnabled, measureInteraction, printInteractions, printOpportunities, printStats, rankOpportunities, reset, subscribe, summarise as summariseInteraction, useRenderDiagnostics, useTrackedContextValue, useTrackedEffect, useTrackedState, withRenderDetective };
|
|
379
689
|
//# sourceMappingURL=index.js.map
|
|
380
690
|
//# sourceMappingURL=index.js.map
|