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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/dist/babel.cjs +2 -2
  3. package/dist/babel.js +1 -1
  4. package/dist/{chunk-6YPJQ7M5.cjs → chunk-DZ3BZ654.cjs} +88 -4
  5. package/dist/chunk-DZ3BZ654.cjs.map +1 -0
  6. package/dist/chunk-H5RG2EPP.cjs +75 -0
  7. package/dist/chunk-H5RG2EPP.cjs.map +1 -0
  8. package/dist/{chunk-LSIXHWQD.js → chunk-HOTY7J3X.js} +14 -4
  9. package/dist/chunk-HOTY7J3X.js.map +1 -0
  10. package/dist/{chunk-QQ4VARSD.cjs → chunk-JD4IJ4MN.cjs} +14 -4
  11. package/dist/chunk-JD4IJ4MN.cjs.map +1 -0
  12. package/dist/chunk-LILK23YH.js +71 -0
  13. package/dist/chunk-LILK23YH.js.map +1 -0
  14. package/dist/{chunk-PSCFSJ65.js → chunk-MCIQMYNR.js} +12 -5
  15. package/dist/chunk-MCIQMYNR.js.map +1 -0
  16. package/dist/{chunk-BPNLNIHU.cjs → chunk-PHYYA67T.cjs} +12 -5
  17. package/dist/chunk-PHYYA67T.cjs.map +1 -0
  18. package/dist/{chunk-MSISKPST.js → chunk-QOGTEVBP.js} +88 -4
  19. package/dist/chunk-QOGTEVBP.js.map +1 -0
  20. package/dist/core.cjs +18 -18
  21. package/dist/core.d.cts +38 -2
  22. package/dist/core.d.ts +38 -2
  23. package/dist/core.js +1 -1
  24. package/dist/index.cjs +354 -31
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +172 -4
  27. package/dist/index.d.ts +172 -4
  28. package/dist/index.js +316 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/overlay.cjs +6 -6
  31. package/dist/overlay.cjs.map +1 -1
  32. package/dist/overlay.js +4 -4
  33. package/dist/overlay.js.map +1 -1
  34. package/dist/testing.cjs +20 -0
  35. package/dist/testing.cjs.map +1 -0
  36. package/dist/testing.d.cts +62 -0
  37. package/dist/testing.d.ts +62 -0
  38. package/dist/testing.js +3 -0
  39. package/dist/testing.js.map +1 -0
  40. package/dist/{types-BWVaQPHb.d.cts → types-gl13xwmN.d.cts} +5 -0
  41. package/dist/{types-BWVaQPHb.d.ts → types-gl13xwmN.d.ts} +5 -0
  42. package/dist/vite.cjs +9 -6
  43. package/dist/vite.cjs.map +1 -1
  44. package/dist/vite.js +8 -5
  45. package/dist/vite.js.map +1 -1
  46. package/package.json +6 -1
  47. package/dist/chunk-6YPJQ7M5.cjs.map +0 -1
  48. package/dist/chunk-BPNLNIHU.cjs.map +0 -1
  49. package/dist/chunk-LSIXHWQD.js.map +0 -1
  50. package/dist/chunk-MSISKPST.js.map +0 -1
  51. package/dist/chunk-PSCFSJ65.js.map +0 -1
  52. package/dist/chunk-QQ4VARSD.cjs.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,7 +1,130 @@
1
- import { b as RenderEvent, R as RenderReason, j as Confidence, a as DetectiveOptions, D as DetectiveConfig, A as AppStats, d as ComponentStats } from './types-BWVaQPHb.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-BWVaQPHb.js';
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[]): Explanation | undefined;
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-LSIXHWQD.js';
2
- export { explainEvents, formatExplanation } from './chunk-LSIXHWQD.js';
3
- import { getDetective, inspect, shallowEqual, diffProps, formatInspected } from './chunk-MSISKPST.js';
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 = explainEvents(componentName2, getEvents());
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