react-render-detective 0.3.0 → 0.5.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/CHANGELOG.md +244 -0
- package/README.md +1 -1
- package/dist/{chunk-QOGTEVBP.js → chunk-DI5STJM4.js} +39 -3
- package/dist/chunk-DI5STJM4.js.map +1 -0
- package/dist/chunk-H5RG2EPP.cjs +75 -0
- package/dist/chunk-H5RG2EPP.cjs.map +1 -0
- package/dist/chunk-LILK23YH.js +71 -0
- package/dist/chunk-LILK23YH.js.map +1 -0
- package/dist/{chunk-DZ3BZ654.cjs → chunk-OSPTERGK.cjs} +39 -3
- package/dist/chunk-OSPTERGK.cjs.map +1 -0
- package/dist/core.cjs +18 -18
- package/dist/core.d.cts +14 -0
- package/dist/core.d.ts +14 -0
- package/dist/core.js +1 -1
- package/dist/index.cjs +466 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -2
- package/dist/index.d.ts +160 -2
- package/dist/index.js +429 -24
- package/dist/index.js.map +1 -1
- package/dist/overlay.cjs +2 -2
- package/dist/overlay.js +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/package.json +14 -4
- package/dist/chunk-DZ3BZ654.cjs.map +0 -1
- package/dist/chunk-QOGTEVBP.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var chunkJD4IJ4MN_cjs = require('./chunk-JD4IJ4MN.cjs');
|
|
4
|
-
var
|
|
4
|
+
var chunkOSPTERGK_cjs = require('./chunk-OSPTERGK.cjs');
|
|
5
|
+
var chunkH5RG2EPP_cjs = require('./chunk-H5RG2EPP.cjs');
|
|
5
6
|
var React = require('react');
|
|
6
7
|
var jsxRuntime = require('react/jsx-runtime');
|
|
7
8
|
|
|
@@ -25,6 +26,40 @@ function _interopNamespace(e) {
|
|
|
25
26
|
|
|
26
27
|
var React__namespace = /*#__PURE__*/_interopNamespace(React);
|
|
27
28
|
|
|
29
|
+
// src/console/style.ts
|
|
30
|
+
var PALETTE = {
|
|
31
|
+
plain: "",
|
|
32
|
+
dim: "color:#8a94a6",
|
|
33
|
+
strong: "font-weight:600",
|
|
34
|
+
good: "color:#1f9d5b",
|
|
35
|
+
warn: "color:#c2820a",
|
|
36
|
+
bad: "color:#d1493f;font-weight:600"
|
|
37
|
+
};
|
|
38
|
+
var supportsStyling = () => {
|
|
39
|
+
try {
|
|
40
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function styled(segments) {
|
|
46
|
+
if (!supportsStyling()) {
|
|
47
|
+
return [segments.map((s) => typeof s === "string" ? s : s[0]).join("")];
|
|
48
|
+
}
|
|
49
|
+
let format = "";
|
|
50
|
+
const styles = [];
|
|
51
|
+
for (const segment of segments) {
|
|
52
|
+
if (typeof segment === "string") {
|
|
53
|
+
format += segment.replace(/%/g, "%%");
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const [text, tone] = segment;
|
|
57
|
+
format += `%c${text.replace(/%/g, "%%")}%c`;
|
|
58
|
+
styles.push(PALETTE[tone], "");
|
|
59
|
+
}
|
|
60
|
+
return [format, ...styles];
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
// src/console/reporter.ts
|
|
29
64
|
var ICON = {
|
|
30
65
|
normal: "\xB7",
|
|
@@ -33,30 +68,102 @@ var ICON = {
|
|
|
33
68
|
"very-slow": "\u25B2\u25B2",
|
|
34
69
|
critical: "\u25A0"
|
|
35
70
|
};
|
|
71
|
+
var BATCH_WINDOW_MS = 400;
|
|
36
72
|
function attachConsoleReporter(detective) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
let pending = [];
|
|
74
|
+
let timer;
|
|
75
|
+
const flush = () => {
|
|
76
|
+
timer = void 0;
|
|
77
|
+
const batch = pending;
|
|
78
|
+
pending = [];
|
|
79
|
+
if (batch.length === 0) return;
|
|
40
80
|
try {
|
|
41
|
-
|
|
42
|
-
else printConcise(event, detective.config.slowRenderThreshold);
|
|
81
|
+
printBatch(batch, detective.config.slowRenderThreshold);
|
|
43
82
|
} catch {
|
|
44
83
|
}
|
|
84
|
+
};
|
|
85
|
+
const unsubscribe = detective.subscribe((event) => {
|
|
86
|
+
const mode = detective.config.mode;
|
|
87
|
+
if (mode === "silent") return;
|
|
88
|
+
if (mode === "verbose") {
|
|
89
|
+
try {
|
|
90
|
+
printVerbose(event);
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
pending.push(event);
|
|
96
|
+
if (timer === void 0) {
|
|
97
|
+
timer = setTimeout(flush, BATCH_WINDOW_MS);
|
|
98
|
+
timer.unref?.();
|
|
99
|
+
}
|
|
45
100
|
});
|
|
101
|
+
return () => {
|
|
102
|
+
unsubscribe();
|
|
103
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
104
|
+
};
|
|
46
105
|
}
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
106
|
+
function printBatch(batch, slowThreshold) {
|
|
107
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
108
|
+
let totalMs = 0;
|
|
109
|
+
let avoidable = 0;
|
|
110
|
+
for (const event of batch) {
|
|
111
|
+
const key = event.component.name;
|
|
112
|
+
const entry = byComponent.get(key) ?? {
|
|
113
|
+
name: key,
|
|
114
|
+
source: event.component.source,
|
|
115
|
+
count: 0,
|
|
116
|
+
totalMs: 0,
|
|
117
|
+
reasons: /* @__PURE__ */ new Map(),
|
|
118
|
+
notableReasons: /* @__PURE__ */ new Map(),
|
|
119
|
+
avoidable: 0,
|
|
120
|
+
remount: false,
|
|
121
|
+
slow: false
|
|
122
|
+
};
|
|
123
|
+
entry.count++;
|
|
124
|
+
entry.totalMs += event.timings.selfDuration;
|
|
125
|
+
entry.reasons.set(event.diagnosis.reason, (entry.reasons.get(event.diagnosis.reason) ?? 0) + 1);
|
|
126
|
+
if (event.diagnosis.potentiallyAvoidable || event.timings.selfDuration >= slowThreshold) {
|
|
127
|
+
entry.notableReasons.set(event.diagnosis.reason, (entry.notableReasons.get(event.diagnosis.reason) ?? 0) + 1);
|
|
128
|
+
}
|
|
129
|
+
if (event.diagnosis.potentiallyAvoidable) entry.avoidable++;
|
|
130
|
+
if (event.diagnosis.summary.includes("rebuilt")) entry.remount = true;
|
|
131
|
+
if (event.timings.selfDuration >= slowThreshold) entry.slow = true;
|
|
132
|
+
if (!entry.worst || event.timings.selfDuration > entry.worst.timings.selfDuration) entry.worst = event;
|
|
133
|
+
byComponent.set(key, entry);
|
|
134
|
+
totalMs += event.timings.selfDuration;
|
|
135
|
+
if (event.diagnosis.potentiallyAvoidable) avoidable++;
|
|
136
|
+
}
|
|
137
|
+
const notable = [...byComponent.values()].filter((a) => a.avoidable > 0 || a.slow || a.remount);
|
|
138
|
+
if (notable.length === 0) return;
|
|
139
|
+
const ranked = [...notable].sort((a, b) => b.totalMs - a.totalMs);
|
|
140
|
+
const anySlow = ranked.some((a) => a.slow);
|
|
141
|
+
const segments = [
|
|
142
|
+
["[RRD] ", "dim"],
|
|
143
|
+
[`${batch.length} render${batch.length === 1 ? "" : "s"}`, "strong"],
|
|
144
|
+
[` \xB7 ${totalMs.toFixed(1)}ms`, "dim"]
|
|
145
|
+
];
|
|
146
|
+
if (avoidable > 0) segments.push([` \xB7 ${avoidable} potentially avoidable`, "warn"]);
|
|
147
|
+
for (const a of ranked.slice(0, 8)) {
|
|
148
|
+
const reasonSource = a.notableReasons.size > 0 ? a.notableReasons : a.reasons;
|
|
149
|
+
const reason = [...reasonSource.entries()].sort((x, y) => y[1] - x[1])[0]?.[0] ?? "unknown";
|
|
150
|
+
const tone = a.remount ? "bad" : a.slow ? "warn" : a.avoidable > 0 ? "warn" : "good";
|
|
151
|
+
const label2 = ` ${ICON[a.slow ? "slow" : "normal"]} ${a.name}${a.count > 1 ? ` \xD7${a.count}` : ""}`;
|
|
152
|
+
segments.push("\n", [label2.padEnd(30), tone], [`${a.totalMs.toFixed(1)}ms`, "plain"], [` ${reason}`, "dim"]);
|
|
153
|
+
if (a.remount) segments.push([" rebuilt", "bad"]);
|
|
154
|
+
if (a.avoidable > 0) segments.push([` ${a.avoidable} avoidable`, "warn"]);
|
|
155
|
+
if (a.source) segments.push(["\n " + a.source, "dim"]);
|
|
156
|
+
}
|
|
157
|
+
const worst = ranked[0]?.worst;
|
|
158
|
+
segments.push(
|
|
159
|
+
"\n",
|
|
160
|
+
[
|
|
161
|
+
worst?.diagnosis.suggestion ? ` \u2192 ${worst.diagnosis.suggestion}` : " \u2192 rrd.printOpportunities() to rank everything by recoverable time",
|
|
162
|
+
"dim"
|
|
163
|
+
]
|
|
164
|
+
);
|
|
165
|
+
const log = anySlow ? console.warn : console.log;
|
|
166
|
+
log(...styled(segments));
|
|
60
167
|
}
|
|
61
168
|
function printVerbose(event) {
|
|
62
169
|
const { diagnosis, timings } = event;
|
|
@@ -79,12 +186,10 @@ function printVerbose(event) {
|
|
|
79
186
|
for (const c of event.changedProps) console.log(propLine(c));
|
|
80
187
|
console.groupEnd();
|
|
81
188
|
}
|
|
82
|
-
if (event.unchangedProps.length > 0) {
|
|
83
|
-
console.log(`Props same: ${event.unchangedProps.join(", ")}`);
|
|
84
|
-
}
|
|
189
|
+
if (event.unchangedProps.length > 0) console.log(`Props same: ${event.unchangedProps.join(", ")}`);
|
|
85
190
|
if (event.trackedState.length > 0) {
|
|
86
191
|
console.log(
|
|
87
|
-
`State: ${event.trackedState.map((s) => `${s.name}: ${
|
|
192
|
+
`State: ${event.trackedState.map((s) => `${s.name}: ${chunkOSPTERGK_cjs.formatInspected(s.previous)} \u2192 ${chunkOSPTERGK_cjs.formatInspected(s.current)}`).join(", ")}`
|
|
88
193
|
);
|
|
89
194
|
}
|
|
90
195
|
if (event.contextChanges.length > 0) {
|
|
@@ -101,11 +206,11 @@ function printVerbose(event) {
|
|
|
101
206
|
}
|
|
102
207
|
function propLine(c) {
|
|
103
208
|
const head = `${c.key} (${c.valueType}) \u2014 ${describeKind(c)}`;
|
|
104
|
-
if (c.kind === "added") return `${head}: ${
|
|
105
|
-
if (c.kind === "removed") return `${head}: was ${
|
|
209
|
+
if (c.kind === "added") return `${head}: ${chunkOSPTERGK_cjs.formatInspected(c.current)}`;
|
|
210
|
+
if (c.kind === "removed") return `${head}: was ${chunkOSPTERGK_cjs.formatInspected(c.previous)}`;
|
|
106
211
|
return `${head}
|
|
107
|
-
previous: ${
|
|
108
|
-
current: ${
|
|
212
|
+
previous: ${chunkOSPTERGK_cjs.formatInspected(c.previous)}
|
|
213
|
+
current: ${chunkOSPTERGK_cjs.formatInspected(c.current)}`;
|
|
109
214
|
}
|
|
110
215
|
function describeKind(c) {
|
|
111
216
|
switch (c.kind) {
|
|
@@ -138,10 +243,249 @@ function label(event) {
|
|
|
138
243
|
return "undetermined";
|
|
139
244
|
}
|
|
140
245
|
}
|
|
246
|
+
|
|
247
|
+
// src/core/interactions.ts
|
|
248
|
+
var COMMIT_SLACK_MS = 100;
|
|
249
|
+
var FALLBACK_CLOSE_MS = 50;
|
|
250
|
+
var InteractionTracker = class {
|
|
251
|
+
constructor(capacity = 50) {
|
|
252
|
+
this.capacity = capacity;
|
|
253
|
+
this.records = [];
|
|
254
|
+
this.nextId = 0;
|
|
255
|
+
}
|
|
256
|
+
/** Returns false when the browser cannot report event timing. */
|
|
257
|
+
start() {
|
|
258
|
+
if (this.observer) return true;
|
|
259
|
+
const PO = globalThis.PerformanceObserver;
|
|
260
|
+
const supported = PO?.supportedEntryTypes?.includes("event");
|
|
261
|
+
if (!PO || !supported) return false;
|
|
262
|
+
try {
|
|
263
|
+
const observer = new PO((list) => {
|
|
264
|
+
for (const entry of list.getEntries()) {
|
|
265
|
+
const timing = entry;
|
|
266
|
+
this.record({
|
|
267
|
+
name: timing.name,
|
|
268
|
+
startTime: timing.startTime,
|
|
269
|
+
duration: timing.duration,
|
|
270
|
+
target: describeTarget(timing.target)
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
observer.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
275
|
+
this.observer = observer;
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
stop() {
|
|
282
|
+
this.observer?.disconnect();
|
|
283
|
+
this.observer = void 0;
|
|
284
|
+
}
|
|
285
|
+
/** Is the automatic path available in this browser? */
|
|
286
|
+
get automatic() {
|
|
287
|
+
return this.observer !== void 0;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Time an interaction by hand.
|
|
291
|
+
*
|
|
292
|
+
* The automatic path depends on the Event Timing API, which Safari only
|
|
293
|
+
* gained in 16.4 and which does not fire for synthetic input at all — so
|
|
294
|
+
* anything driven by a test harness records nothing. This measures a specific
|
|
295
|
+
* action instead, up to the paint that follows it, and needs no browser
|
|
296
|
+
* support beyond `performance.now`.
|
|
297
|
+
*/
|
|
298
|
+
measure(label2, action) {
|
|
299
|
+
const startTime = now();
|
|
300
|
+
let handlerMs = 0;
|
|
301
|
+
let finished = false;
|
|
302
|
+
const finish = () => {
|
|
303
|
+
if (finished) return;
|
|
304
|
+
finished = true;
|
|
305
|
+
this.record({ name: label2, startTime, duration: now() - startTime, handlerMs });
|
|
306
|
+
};
|
|
307
|
+
let result;
|
|
308
|
+
try {
|
|
309
|
+
result = action();
|
|
310
|
+
handlerMs = now() - startTime;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
handlerMs = now() - startTime;
|
|
313
|
+
finish();
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
const raf = globalThis.requestAnimationFrame;
|
|
317
|
+
if (raf) raf(() => raf(finish));
|
|
318
|
+
setTimeout(finish, FALLBACK_CLOSE_MS);
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
/** Exposed for tests and for `measure`. */
|
|
322
|
+
record(timing) {
|
|
323
|
+
const record = {
|
|
324
|
+
id: `interaction_${++this.nextId}`,
|
|
325
|
+
type: timing.name,
|
|
326
|
+
target: timing.target,
|
|
327
|
+
startTime: timing.startTime,
|
|
328
|
+
durationMs: timing.duration,
|
|
329
|
+
handlerMs: timing.handlerMs,
|
|
330
|
+
renders: [],
|
|
331
|
+
renderTimeMs: 0,
|
|
332
|
+
avoidableRenderTimeMs: 0
|
|
333
|
+
};
|
|
334
|
+
this.records.push(record);
|
|
335
|
+
if (this.records.length > this.capacity) this.records.shift();
|
|
336
|
+
return record;
|
|
337
|
+
}
|
|
338
|
+
clear() {
|
|
339
|
+
this.records.length = 0;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Joins render events to interactions by commit time. A render belongs to an
|
|
343
|
+
* interaction when it committed between the event starting and shortly after
|
|
344
|
+
* it finished — React commits just after the event handler returns.
|
|
345
|
+
*/
|
|
346
|
+
attribute(events) {
|
|
347
|
+
for (const record of this.records) {
|
|
348
|
+
const from = record.startTime;
|
|
349
|
+
const to = record.startTime + record.durationMs + COMMIT_SLACK_MS;
|
|
350
|
+
record.renders = events.filter((e) => e.timings.commitTime >= from && e.timings.commitTime <= to);
|
|
351
|
+
record.renderTimeMs = record.renders.reduce((a, e) => a + e.timings.selfDuration, 0);
|
|
352
|
+
record.avoidableRenderTimeMs = record.renders.filter((e) => e.diagnosis.potentiallyAvoidable).reduce((a, e) => a + e.timings.selfDuration, 0);
|
|
353
|
+
}
|
|
354
|
+
return [...this.records].sort((a, b) => b.durationMs - a.durationMs);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
function summarise(record) {
|
|
358
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const event of record.renders) {
|
|
360
|
+
const entry = byComponent.get(event.component.name) ?? {
|
|
361
|
+
renders: 0,
|
|
362
|
+
totalMs: 0,
|
|
363
|
+
source: event.component.source,
|
|
364
|
+
cause: event.diagnosis.reason
|
|
365
|
+
};
|
|
366
|
+
entry.renders++;
|
|
367
|
+
entry.totalMs += event.timings.selfDuration;
|
|
368
|
+
byComponent.set(event.component.name, entry);
|
|
369
|
+
}
|
|
370
|
+
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);
|
|
371
|
+
const top = contributors[0];
|
|
372
|
+
const accounted = (record.handlerMs ?? record.durationMs) + record.renderTimeMs;
|
|
373
|
+
const idleWindow = record.handlerMs !== void 0 && record.durationMs > accounted * 3 && record.durationMs - accounted > 100;
|
|
374
|
+
const effectiveMs = idleWindow ? accounted : record.durationMs;
|
|
375
|
+
const share = effectiveMs > 0 ? record.renderTimeMs / effectiveMs : 0;
|
|
376
|
+
let headline;
|
|
377
|
+
let nextStep;
|
|
378
|
+
let confidence = "medium";
|
|
379
|
+
if (idleWindow) {
|
|
380
|
+
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.`;
|
|
381
|
+
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.";
|
|
382
|
+
return { interaction: record, contributors, headline, nextStep, confidence: "medium" };
|
|
383
|
+
}
|
|
384
|
+
if (record.renders.length === 0) {
|
|
385
|
+
headline = `${record.type} took ${fmt(record.durationMs)}, and no instrumented component rendered inside it.`;
|
|
386
|
+
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.";
|
|
387
|
+
confidence = "low";
|
|
388
|
+
} else if (share >= 0.4 && record.avoidableRenderTimeMs > 0) {
|
|
389
|
+
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.`;
|
|
390
|
+
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.";
|
|
391
|
+
confidence = "high";
|
|
392
|
+
} else if (share >= 0.4) {
|
|
393
|
+
headline = `${record.type} took ${fmt(record.durationMs)}; ${fmt(record.renderTimeMs)} of it was rendering, all of it explained by real input changes.`;
|
|
394
|
+
nextStep = "This is genuine work. Make the renders cheaper rather than fewer \u2014 or do less of it per interaction.";
|
|
395
|
+
confidence = "high";
|
|
396
|
+
} else {
|
|
397
|
+
headline = `${record.type} took ${fmt(record.durationMs)}, but only ${fmt(record.renderTimeMs)} was React rendering.`;
|
|
398
|
+
nextStep = "Most of the cost is outside rendering \u2014 event handlers, layout or paint. A browser profile will show more than this tool can.";
|
|
399
|
+
confidence = "medium";
|
|
400
|
+
}
|
|
401
|
+
return { interaction: record, contributors, headline, nextStep, confidence };
|
|
402
|
+
}
|
|
403
|
+
function formatInteraction(summary) {
|
|
404
|
+
const { interaction: i } = summary;
|
|
405
|
+
const lines = [
|
|
406
|
+
`${i.type}${i.target ? ` on ${i.target}` : ""} ${fmt(i.durationMs)}`,
|
|
407
|
+
"",
|
|
408
|
+
summary.headline
|
|
409
|
+
];
|
|
410
|
+
if (summary.contributors.length > 0) {
|
|
411
|
+
lines.push("", "Rendering inside this interaction");
|
|
412
|
+
for (const c of summary.contributors.slice(0, 8)) {
|
|
413
|
+
lines.push(
|
|
414
|
+
` ${c.component.padEnd(22)} ${String(c.renders).padStart(4)} render(s) ${fmt(c.totalMs).padStart(8)} ${c.cause}`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
lines.push("", `Next step`, ` ${summary.nextStep}`, "", `Confidence: ${summary.confidence}`);
|
|
419
|
+
return lines.join("\n");
|
|
420
|
+
}
|
|
421
|
+
function describeTarget(target) {
|
|
422
|
+
if (!target || typeof target !== "object") return void 0;
|
|
423
|
+
const el = target;
|
|
424
|
+
if (!el.tagName) return void 0;
|
|
425
|
+
const tag = el.tagName.toLowerCase();
|
|
426
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
427
|
+
const className = typeof el.className === "string" ? el.className.trim().split(/\s+/)[0] : void 0;
|
|
428
|
+
if (className) return `${tag}.${className}`;
|
|
429
|
+
const text = el.textContent?.trim().slice(0, 20);
|
|
430
|
+
return text ? `${tag} "${text}"` : tag;
|
|
431
|
+
}
|
|
432
|
+
var fmt = (ms) => `${ms.toFixed(1)}ms`;
|
|
433
|
+
var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
434
|
+
|
|
435
|
+
// src/core/opportunities.ts
|
|
436
|
+
var DEFAULT_MIN_SAVING_MS = 1;
|
|
437
|
+
function rankOpportunities({ events, lifecycles, minSavingMs = DEFAULT_MIN_SAVING_MS }) {
|
|
438
|
+
const names = new Set(events.map((e) => e.component.name));
|
|
439
|
+
const out = [];
|
|
440
|
+
for (const name of names) {
|
|
441
|
+
const lifecycle = lifecycles.get(name);
|
|
442
|
+
const explanation = chunkJD4IJ4MN_cjs.explainEvents(name, events, lifecycle);
|
|
443
|
+
if (!explanation) continue;
|
|
444
|
+
const mounts = events.filter((e) => e.component.name === name && e.phase === "mount");
|
|
445
|
+
const averageMountCost = mounts.length ? mounts.reduce((a, e) => a + e.timings.selfDuration, 0) / mounts.length : 0;
|
|
446
|
+
const remountSaving = explanation.remounts * averageMountCost;
|
|
447
|
+
const estimatedSavingMs = explanation.estimatedAvoidableTime + remountSaving;
|
|
448
|
+
if (estimatedSavingMs < minSavingMs) continue;
|
|
449
|
+
out.push({
|
|
450
|
+
component: name,
|
|
451
|
+
source: explanation.source,
|
|
452
|
+
estimatedSavingMs,
|
|
453
|
+
avoidableRenders: explanation.potentiallyAvoidableRenders,
|
|
454
|
+
remounts: explanation.remounts,
|
|
455
|
+
averageSelfDuration: explanation.averageSelfDuration,
|
|
456
|
+
summary: explanation.headline,
|
|
457
|
+
nextStep: explanation.nextStep,
|
|
458
|
+
confidence: explanation.confidence
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return out.sort((a, b) => b.estimatedSavingMs - a.estimatedSavingMs);
|
|
462
|
+
}
|
|
463
|
+
function formatOpportunities(opportunities) {
|
|
464
|
+
if (opportunities.length === 0) {
|
|
465
|
+
return "React Render Detective\n\nNo measurable render waste found yet. Interact with the app and try again.";
|
|
466
|
+
}
|
|
467
|
+
const lines = [
|
|
468
|
+
"React Render Detective \u2014 where to spend your next hour",
|
|
469
|
+
"",
|
|
470
|
+
"Ranked by estimated recoverable time. These are estimates, not promises:",
|
|
471
|
+
"measure each fix.",
|
|
472
|
+
""
|
|
473
|
+
];
|
|
474
|
+
for (const [index, o] of opportunities.entries()) {
|
|
475
|
+
lines.push(
|
|
476
|
+
`${String(index + 1).padStart(2)}. ${o.component}${o.source ? ` ${o.source}` : ""}`,
|
|
477
|
+
` ~${o.estimatedSavingMs.toFixed(0)}ms recoverable ${o.avoidableRenders} avoidable render${o.avoidableRenders === 1 ? "" : "s"}${o.remounts > 0 ? `, ${o.remounts}\xD7 rebuilt` : ""} (confidence: ${o.confidence})`,
|
|
478
|
+
` ${o.summary}`,
|
|
479
|
+
` \u2192 ${o.nextStep}`,
|
|
480
|
+
""
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return lines.join("\n");
|
|
484
|
+
}
|
|
141
485
|
var AncestryContext = React.createContext(void 0);
|
|
142
486
|
AncestryContext.displayName = "RenderDetectiveAncestry";
|
|
143
487
|
function useInstrumentedNode(name, props, source) {
|
|
144
|
-
const detective =
|
|
488
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
145
489
|
const parent = React.useContext(AncestryContext);
|
|
146
490
|
const [node] = React.useState(
|
|
147
491
|
() => detective.enabled ? detective.createNode(name, parent, source) : void 0
|
|
@@ -191,7 +535,7 @@ var IS_WRAPPER = /* @__PURE__ */ Symbol.for("react-render-detective.wrapper");
|
|
|
191
535
|
function withRenderDetective(Component, options = {}) {
|
|
192
536
|
if (Component[IS_WRAPPER]) return Component;
|
|
193
537
|
const name = options.name ?? componentName(Component);
|
|
194
|
-
|
|
538
|
+
chunkOSPTERGK_cjs.getDetective().noteDefinition(name, options.source, options.declaredInRender === true);
|
|
195
539
|
function RenderDetected(props) {
|
|
196
540
|
const { node, onRender } = useInstrumentedNode(name, props, options.source);
|
|
197
541
|
return renderInstrumented(node, onRender, /* @__PURE__ */ jsxRuntime.jsx(Component, { ...props }));
|
|
@@ -207,7 +551,7 @@ function RenderDetective({ name, children }) {
|
|
|
207
551
|
return renderInstrumented(node, onRender, children);
|
|
208
552
|
}
|
|
209
553
|
function useRenderDiagnostics(name, props) {
|
|
210
|
-
const detective =
|
|
554
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
211
555
|
const parent = React.useContext(AncestryContext);
|
|
212
556
|
const [node] = React.useState(() => {
|
|
213
557
|
if (!detective.enabled) return void 0;
|
|
@@ -242,7 +586,7 @@ function useRenderDiagnostics(name, props) {
|
|
|
242
586
|
}
|
|
243
587
|
var useOwnerId = typeof React__namespace.useId === "function" ? React__namespace.useId : () => "";
|
|
244
588
|
function useTrackedState(name, initial) {
|
|
245
|
-
const detective =
|
|
589
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
246
590
|
const node = React.useContext(AncestryContext);
|
|
247
591
|
const ownerId = useOwnerId();
|
|
248
592
|
const [state, setState] = React.useState(initial);
|
|
@@ -252,8 +596,8 @@ function useTrackedState(name, initial) {
|
|
|
252
596
|
if (node && owns && !Object.is(previous.current, state)) {
|
|
253
597
|
detective.recordStateChange(node, {
|
|
254
598
|
name,
|
|
255
|
-
previous:
|
|
256
|
-
current:
|
|
599
|
+
previous: chunkOSPTERGK_cjs.inspect(previous.current, detective.config.inspection),
|
|
600
|
+
current: chunkOSPTERGK_cjs.inspect(state, detective.config.inspection)
|
|
257
601
|
});
|
|
258
602
|
previous.current = state;
|
|
259
603
|
} else if (!owns) {
|
|
@@ -262,7 +606,7 @@ function useTrackedState(name, initial) {
|
|
|
262
606
|
return [state, setState];
|
|
263
607
|
}
|
|
264
608
|
function useTrackedEffect(name, effect, deps) {
|
|
265
|
-
const detective =
|
|
609
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
266
610
|
const previous = React.useRef(void 0);
|
|
267
611
|
const changed = React.useRef([]);
|
|
268
612
|
if (detective.enabled) {
|
|
@@ -278,7 +622,7 @@ function useTrackedEffect(name, effect, deps) {
|
|
|
278
622
|
}, deps);
|
|
279
623
|
}
|
|
280
624
|
function useTrackedContextValue(contextName, value) {
|
|
281
|
-
const detective =
|
|
625
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
282
626
|
const previous = React.useRef(void 0);
|
|
283
627
|
const first = React.useRef(true);
|
|
284
628
|
if (detective.enabled) {
|
|
@@ -286,8 +630,8 @@ function useTrackedContextValue(contextName, value) {
|
|
|
286
630
|
first.current = false;
|
|
287
631
|
} else if (!Object.is(previous.current, value)) {
|
|
288
632
|
const prev = previous.current;
|
|
289
|
-
const equal =
|
|
290
|
-
const changedKeys = isRecord(prev) && isRecord(value) ?
|
|
633
|
+
const equal = chunkOSPTERGK_cjs.shallowEqual(prev, value, detective.config);
|
|
634
|
+
const changedKeys = isRecord(prev) && isRecord(value) ? chunkOSPTERGK_cjs.diffProps(prev, value, detective.config).changed.map((c) => c.key) : [];
|
|
291
635
|
detective.recordContextChange({
|
|
292
636
|
contextName,
|
|
293
637
|
changedKeys,
|
|
@@ -305,12 +649,21 @@ function isRecord(v) {
|
|
|
305
649
|
|
|
306
650
|
// src/index.ts
|
|
307
651
|
var REPORTER = /* @__PURE__ */ Symbol.for("react-render-detective.reporter");
|
|
652
|
+
var INTERACTIONS = /* @__PURE__ */ Symbol.for("react-render-detective.interactions");
|
|
653
|
+
function tracker() {
|
|
654
|
+
const g = globalThis;
|
|
655
|
+
if (!g[INTERACTIONS]) g[INTERACTIONS] = new InteractionTracker();
|
|
656
|
+
return g[INTERACTIONS];
|
|
657
|
+
}
|
|
308
658
|
function init(options = {}) {
|
|
309
|
-
const detective =
|
|
659
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
310
660
|
detective.init(options);
|
|
311
661
|
const g = globalThis;
|
|
312
662
|
g[REPORTER]?.();
|
|
313
663
|
g[REPORTER] = void 0;
|
|
664
|
+
if (detective.enabled) {
|
|
665
|
+
tracker().start();
|
|
666
|
+
}
|
|
314
667
|
if (detective.enabled && detective.config.mode !== "silent") {
|
|
315
668
|
g[REPORTER] = attachConsoleReporter(detective);
|
|
316
669
|
} else if (!detective.enabled) {
|
|
@@ -320,41 +673,85 @@ function init(options = {}) {
|
|
|
320
673
|
}
|
|
321
674
|
}
|
|
322
675
|
function configure(options) {
|
|
323
|
-
|
|
676
|
+
chunkOSPTERGK_cjs.getDetective().configure(options);
|
|
324
677
|
}
|
|
325
678
|
function getConfig() {
|
|
326
|
-
return
|
|
679
|
+
return chunkOSPTERGK_cjs.getDetective().config;
|
|
327
680
|
}
|
|
328
681
|
function isEnabled() {
|
|
329
|
-
return
|
|
682
|
+
return chunkOSPTERGK_cjs.getDetective().enabled;
|
|
330
683
|
}
|
|
331
684
|
function getEvents() {
|
|
332
|
-
return
|
|
685
|
+
return chunkOSPTERGK_cjs.getDetective().getEvents();
|
|
333
686
|
}
|
|
334
687
|
function getStats() {
|
|
335
|
-
return
|
|
688
|
+
return chunkOSPTERGK_cjs.getDetective().getStats();
|
|
336
689
|
}
|
|
337
690
|
function getComponentStats(name) {
|
|
338
|
-
return
|
|
691
|
+
return chunkOSPTERGK_cjs.getDetective().getComponentStats(name);
|
|
339
692
|
}
|
|
340
693
|
function subscribe(listener) {
|
|
341
|
-
return
|
|
694
|
+
return chunkOSPTERGK_cjs.getDetective().subscribe(listener);
|
|
342
695
|
}
|
|
343
696
|
function clear() {
|
|
344
|
-
|
|
697
|
+
chunkOSPTERGK_cjs.getDetective().clear();
|
|
698
|
+
tracker().clear();
|
|
345
699
|
}
|
|
346
700
|
function reset() {
|
|
347
701
|
const g = globalThis;
|
|
348
702
|
g[REPORTER]?.();
|
|
349
703
|
g[REPORTER] = void 0;
|
|
350
|
-
|
|
704
|
+
g[INTERACTIONS]?.stop();
|
|
705
|
+
g[INTERACTIONS] = void 0;
|
|
706
|
+
chunkOSPTERGK_cjs.getDetective().reset();
|
|
351
707
|
}
|
|
352
708
|
function explain(componentName2) {
|
|
353
709
|
const explanation = explainStructured(componentName2);
|
|
354
710
|
return explanation ? chunkJD4IJ4MN_cjs.formatExplanation(explanation) : void 0;
|
|
355
711
|
}
|
|
356
712
|
function explainStructured(componentName2) {
|
|
357
|
-
return chunkJD4IJ4MN_cjs.explainEvents(componentName2, getEvents(),
|
|
713
|
+
return chunkJD4IJ4MN_cjs.explainEvents(componentName2, getEvents(), chunkOSPTERGK_cjs.getDetective().lifecycleOf(componentName2));
|
|
714
|
+
}
|
|
715
|
+
function getRenderProfile(scenario) {
|
|
716
|
+
const remounts = {};
|
|
717
|
+
for (const stats of chunkOSPTERGK_cjs.getDetective().getComponentStats()) remounts[stats.name] = stats.remountCount;
|
|
718
|
+
return chunkH5RG2EPP_cjs.profileFromEvents(scenario, getEvents(), remounts);
|
|
719
|
+
}
|
|
720
|
+
function getInteractions() {
|
|
721
|
+
return tracker().attribute(getEvents());
|
|
722
|
+
}
|
|
723
|
+
function explainInteractionStructured(id) {
|
|
724
|
+
const records = getInteractions();
|
|
725
|
+
const record = id ? records.find((r) => r.id === id) : records[0];
|
|
726
|
+
return record ? summarise(record) : void 0;
|
|
727
|
+
}
|
|
728
|
+
function explainInteraction(id) {
|
|
729
|
+
const summary = explainInteractionStructured(id);
|
|
730
|
+
return summary ? formatInteraction(summary) : void 0;
|
|
731
|
+
}
|
|
732
|
+
function printInteractions(limit = 5) {
|
|
733
|
+
const records = getInteractions().slice(0, limit);
|
|
734
|
+
if (records.length === 0) {
|
|
735
|
+
console.log(
|
|
736
|
+
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."
|
|
737
|
+
);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
console.log(records.map((r) => formatInteraction(summarise(r))).join("\n\n"));
|
|
741
|
+
}
|
|
742
|
+
function measureInteraction(label2, action) {
|
|
743
|
+
return tracker().measure(label2, action);
|
|
744
|
+
}
|
|
745
|
+
function getOpportunities(limit = 10) {
|
|
746
|
+
const detective = chunkOSPTERGK_cjs.getDetective();
|
|
747
|
+
const lifecycles = /* @__PURE__ */ new Map();
|
|
748
|
+
for (const stats of detective.getComponentStats()) {
|
|
749
|
+
lifecycles.set(stats.name, { remounts: stats.remountCount });
|
|
750
|
+
}
|
|
751
|
+
return rankOpportunities({ events: detective.getEvents(), lifecycles }).slice(0, limit);
|
|
752
|
+
}
|
|
753
|
+
function printOpportunities(limit = 10) {
|
|
754
|
+
console.log(formatOpportunities(getOpportunities(limit)));
|
|
358
755
|
}
|
|
359
756
|
function printStats() {
|
|
360
757
|
const s = getStats();
|
|
@@ -401,6 +798,14 @@ var ReactRenderDetective = {
|
|
|
401
798
|
reset,
|
|
402
799
|
explain,
|
|
403
800
|
explainStructured,
|
|
801
|
+
getOpportunities,
|
|
802
|
+
printOpportunities,
|
|
803
|
+
getInteractions,
|
|
804
|
+
explainInteraction,
|
|
805
|
+
explainInteractionStructured,
|
|
806
|
+
printInteractions,
|
|
807
|
+
measureInteraction,
|
|
808
|
+
getRenderProfile,
|
|
404
809
|
printStats
|
|
405
810
|
};
|
|
406
811
|
|
|
@@ -412,21 +817,34 @@ Object.defineProperty(exports, "formatExplanation", {
|
|
|
412
817
|
enumerable: true,
|
|
413
818
|
get: function () { return chunkJD4IJ4MN_cjs.formatExplanation; }
|
|
414
819
|
});
|
|
820
|
+
exports.InteractionTracker = InteractionTracker;
|
|
415
821
|
exports.ReactRenderDetective = ReactRenderDetective;
|
|
416
822
|
exports.RenderDetective = RenderDetective;
|
|
417
823
|
exports.clear = clear;
|
|
418
824
|
exports.configure = configure;
|
|
419
825
|
exports.explain = explain;
|
|
826
|
+
exports.explainInteraction = explainInteraction;
|
|
827
|
+
exports.explainInteractionStructured = explainInteractionStructured;
|
|
420
828
|
exports.explainStructured = explainStructured;
|
|
829
|
+
exports.formatInteraction = formatInteraction;
|
|
830
|
+
exports.formatOpportunities = formatOpportunities;
|
|
421
831
|
exports.getComponentStats = getComponentStats;
|
|
422
832
|
exports.getConfig = getConfig;
|
|
423
833
|
exports.getEvents = getEvents;
|
|
834
|
+
exports.getInteractions = getInteractions;
|
|
835
|
+
exports.getOpportunities = getOpportunities;
|
|
836
|
+
exports.getRenderProfile = getRenderProfile;
|
|
424
837
|
exports.getStats = getStats;
|
|
425
838
|
exports.init = init;
|
|
426
839
|
exports.isEnabled = isEnabled;
|
|
840
|
+
exports.measureInteraction = measureInteraction;
|
|
841
|
+
exports.printInteractions = printInteractions;
|
|
842
|
+
exports.printOpportunities = printOpportunities;
|
|
427
843
|
exports.printStats = printStats;
|
|
844
|
+
exports.rankOpportunities = rankOpportunities;
|
|
428
845
|
exports.reset = reset;
|
|
429
846
|
exports.subscribe = subscribe;
|
|
847
|
+
exports.summariseInteraction = summarise;
|
|
430
848
|
exports.useRenderDiagnostics = useRenderDiagnostics;
|
|
431
849
|
exports.useTrackedContextValue = useTrackedContextValue;
|
|
432
850
|
exports.useTrackedEffect = useTrackedEffect;
|