react-render-detective 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1030 @@
1
+ 'use strict';
2
+
3
+ // src/core/inspect.ts
4
+ var REACT_ELEMENT = /* @__PURE__ */ Symbol.for("react.element");
5
+ var REACT_TRANSITIONAL_ELEMENT = /* @__PURE__ */ Symbol.for("react.transitional.element");
6
+ function isReactElement(value) {
7
+ if (typeof value !== "object" || value === null) return false;
8
+ const t = value.$$typeof;
9
+ return t === REACT_ELEMENT || t === REACT_TRANSITIONAL_ELEMENT;
10
+ }
11
+ function isPlainObject(value) {
12
+ if (typeof value !== "object" || value === null) return false;
13
+ if (Array.isArray(value)) return false;
14
+ if (isReactElement(value)) return false;
15
+ const proto = Object.getPrototypeOf(value);
16
+ return proto === Object.prototype || proto === null;
17
+ }
18
+ function valueType(value) {
19
+ if (value === null) return "null";
20
+ if (Array.isArray(value)) return "array";
21
+ if (isReactElement(value)) return "element";
22
+ const t = typeof value;
23
+ if (t === "object") return "object";
24
+ if (t === "function") return "function";
25
+ return t;
26
+ }
27
+ function elementName(value) {
28
+ const type = value.type;
29
+ if (typeof type === "string") return type;
30
+ if (typeof type === "function") return type.displayName ?? type.name ?? "Anonymous";
31
+ return "Element";
32
+ }
33
+ function inspect(value, limits) {
34
+ const budget = { nodes: limits.maxSerializedNodes };
35
+ try {
36
+ return walk(value, limits, budget, limits.depth, /* @__PURE__ */ new WeakSet());
37
+ } catch {
38
+ return { t: "truncated", hint: "inspection failed" };
39
+ }
40
+ }
41
+ function walk(value, limits, budget, depth, seen) {
42
+ if (budget.nodes-- <= 0) return { t: "truncated", hint: "size limit" };
43
+ if (value === void 0) return { t: "undefined" };
44
+ if (value === null) return { t: "primitive", v: null };
45
+ const type = typeof value;
46
+ if (type === "number") return Number.isNaN(value) ? { t: "nan" } : { t: "primitive", v: value };
47
+ if (type === "boolean") return { t: "primitive", v: value };
48
+ if (type === "string") {
49
+ const s = value;
50
+ return {
51
+ t: "primitive",
52
+ v: s.length > limits.maxStringLength ? `${s.slice(0, limits.maxStringLength)}\u2026(+${s.length - limits.maxStringLength})` : s
53
+ };
54
+ }
55
+ if (type === "symbol") return { t: "symbol", v: String(value) };
56
+ if (type === "bigint") return { t: "bigint", v: `${String(value)}n` };
57
+ if (type === "function") {
58
+ const fn = value;
59
+ return { t: "function", name: fn.displayName ?? fn.name ?? "anonymous" };
60
+ }
61
+ const obj = value;
62
+ if (seen.has(obj)) return { t: "circular" };
63
+ seen.add(obj);
64
+ if (isReactElement(obj)) return { t: "element", name: elementName(obj) };
65
+ if (Array.isArray(obj)) {
66
+ const length = obj.length;
67
+ if (depth <= 0) return { t: "array", length };
68
+ const take2 = Math.min(length, limits.maxArrayLength);
69
+ const items = [];
70
+ for (let i = 0; i < take2; i++) items.push(walk(obj[i], limits, budget, depth - 1, seen));
71
+ return { t: "array", length, items, truncated: take2 < length };
72
+ }
73
+ const ctor = objectTag(obj);
74
+ let keys;
75
+ try {
76
+ keys = Object.keys(obj);
77
+ } catch {
78
+ return { t: "object", ctor };
79
+ }
80
+ if (depth <= 0) {
81
+ return { t: "object", ctor, keys: keys.slice(0, limits.maxObjectKeys), truncated: keys.length > limits.maxObjectKeys };
82
+ }
83
+ const take = Math.min(keys.length, limits.maxObjectKeys);
84
+ const entries = {};
85
+ for (let i = 0; i < take; i++) {
86
+ const k = keys[i];
87
+ let v;
88
+ try {
89
+ v = obj[k];
90
+ } catch {
91
+ entries[k] = { t: "truncated", hint: "getter threw" };
92
+ continue;
93
+ }
94
+ entries[k] = walk(v, limits, budget, depth - 1, seen);
95
+ }
96
+ return { t: "object", ctor, entries, truncated: take < keys.length };
97
+ }
98
+ function objectTag(obj) {
99
+ const proto = Object.getPrototypeOf(obj);
100
+ if (proto === Object.prototype || proto === null) return void 0;
101
+ const name = proto?.constructor?.name;
102
+ return typeof name === "string" && name !== "Object" ? name : void 0;
103
+ }
104
+ function formatInspected(node) {
105
+ if (!node) return "\u2026";
106
+ switch (node.t) {
107
+ case "primitive":
108
+ return typeof node.v === "string" ? JSON.stringify(node.v) : String(node.v);
109
+ case "undefined":
110
+ return "undefined";
111
+ case "nan":
112
+ return "NaN";
113
+ case "symbol":
114
+ case "bigint":
115
+ return node.v;
116
+ case "function":
117
+ return `\u0192 ${node.name}()`;
118
+ case "element":
119
+ return `<${node.name} />`;
120
+ case "circular":
121
+ return "[Circular]";
122
+ case "truncated":
123
+ return `[\u2026 ${node.hint}]`;
124
+ case "array": {
125
+ if (!node.items) return `Array(${node.length})`;
126
+ const body = node.items.map(formatInspected).join(", ");
127
+ return `[${body}${node.truncated ? ", \u2026" : ""}]`;
128
+ }
129
+ case "object": {
130
+ const prefix = node.ctor ? `${node.ctor} ` : "";
131
+ if (node.entries) {
132
+ const body = Object.entries(node.entries).map(([k, v]) => `${k}: ${formatInspected(v)}`).join(", ");
133
+ return `${prefix}{ ${body}${node.truncated ? ", \u2026" : ""} }`;
134
+ }
135
+ if (node.keys) return `${prefix}{ ${node.keys.join(", ")}${node.truncated ? ", \u2026" : ""} }`;
136
+ return `${prefix}{\u2026}`;
137
+ }
138
+ }
139
+ }
140
+
141
+ // src/core/config.ts
142
+ function detectDev() {
143
+ try {
144
+ const env = typeof process !== "undefined" ? process?.env : void 0;
145
+ if (env && env.NODE_ENV) return env.NODE_ENV !== "production";
146
+ } catch {
147
+ }
148
+ return false;
149
+ }
150
+ var defaultConfig = {
151
+ enabled: detectDev(),
152
+ mode: "console",
153
+ include: [],
154
+ exclude: [],
155
+ samplingRate: 1,
156
+ maxEvents: 1e3,
157
+ slowRenderThreshold: 16,
158
+ thresholds: { monitor: 5, slow: 16, verySlow: 50, critical: 100 },
159
+ inspection: {
160
+ depth: 1,
161
+ maxObjectKeys: 20,
162
+ maxArrayLength: 20,
163
+ maxStringLength: 120,
164
+ maxSerializedNodes: 200
165
+ },
166
+ compareFunctionSource: false
167
+ };
168
+ function mergeConfig(base, options = {}) {
169
+ const { thresholds, inspection, ...rest } = options;
170
+ const next = {
171
+ ...base,
172
+ ...rest,
173
+ thresholds: { ...base.thresholds, ...thresholds },
174
+ inspection: { ...base.inspection, ...inspection }
175
+ };
176
+ next.samplingRate = clamp(next.samplingRate, 0, 1);
177
+ next.maxEvents = Math.max(1, Math.floor(next.maxEvents));
178
+ next.inspection.depth = clamp(Math.floor(next.inspection.depth), 0, 6);
179
+ return next;
180
+ }
181
+ function clamp(n, lo, hi) {
182
+ if (typeof n !== "number" || Number.isNaN(n)) return lo;
183
+ return Math.min(hi, Math.max(lo, n));
184
+ }
185
+ function matches(name, patterns) {
186
+ for (const p of patterns) {
187
+ if (typeof p === "string" ? p === name : p.test(name)) return true;
188
+ }
189
+ return false;
190
+ }
191
+ function shouldInstrument(name, config) {
192
+ if (matches(name, config.exclude)) return false;
193
+ if (config.include.length > 0 && !matches(name, config.include)) return false;
194
+ return true;
195
+ }
196
+
197
+ // src/core/compare.ts
198
+ var EMPTY = {};
199
+ function diffProps(previous, current, config) {
200
+ const prev = previous ?? EMPTY;
201
+ const next = current ?? EMPTY;
202
+ const changed = [];
203
+ const unchanged = [];
204
+ const keys = /* @__PURE__ */ new Set();
205
+ for (const k in prev) keys.add(k);
206
+ for (const k in next) keys.add(k);
207
+ for (const key of keys) {
208
+ const hadPrev = key in prev;
209
+ const hasNext = key in next;
210
+ const a = prev[key];
211
+ const b = next[key];
212
+ if (hadPrev && hasNext && Object.is(a, b)) {
213
+ unchanged.push(key);
214
+ continue;
215
+ }
216
+ if (!hadPrev) {
217
+ changed.push({ key, kind: "added", valueType: valueType(b), current: snap(b, config) });
218
+ continue;
219
+ }
220
+ if (!hasNext) {
221
+ changed.push({ key, kind: "removed", valueType: valueType(a), previous: snap(a, config) });
222
+ continue;
223
+ }
224
+ changed.push(describeChange(key, a, b, config));
225
+ }
226
+ return { changed, unchanged };
227
+ }
228
+ function describeChange(key, a, b, config) {
229
+ const type = valueType(b);
230
+ const base = {
231
+ key,
232
+ kind: "value",
233
+ valueType: type,
234
+ previous: snap(a, config),
235
+ current: snap(b, config)
236
+ };
237
+ if (typeof a === "function" && typeof b === "function") {
238
+ base.kind = "reference";
239
+ if (config.compareFunctionSource) {
240
+ base.sourceEqual = safeSource(a) === safeSource(b);
241
+ }
242
+ return base;
243
+ }
244
+ const shallow = shallowEqual(a, b, config);
245
+ if (shallow === true) {
246
+ base.kind = "reference";
247
+ base.shallowEqual = true;
248
+ } else if (shallow === false) {
249
+ base.kind = "value";
250
+ base.shallowEqual = false;
251
+ }
252
+ return base;
253
+ }
254
+ function shallowEqual(a, b, config) {
255
+ if (Object.is(a, b)) return true;
256
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
257
+ const { maxObjectKeys, maxArrayLength } = config.inspection;
258
+ if (Array.isArray(a) || Array.isArray(b)) {
259
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
260
+ if (a.length !== b.length) return false;
261
+ if (a.length > maxArrayLength) return void 0;
262
+ for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
263
+ return true;
264
+ }
265
+ if (a instanceof Date || b instanceof Date) {
266
+ return a instanceof Date && b instanceof Date ? a.getTime() === b.getTime() : false;
267
+ }
268
+ if (isReactElement(a) && isReactElement(b)) {
269
+ const ea = a;
270
+ const eb = b;
271
+ if (ea.type !== eb.type || ea.key !== eb.key) return false;
272
+ return shallowEqual(ea.props, eb.props, config);
273
+ }
274
+ if (!isPlainObject(a) || !isPlainObject(b)) return void 0;
275
+ const ka = Object.keys(a);
276
+ const kb = Object.keys(b);
277
+ if (ka.length !== kb.length) return false;
278
+ if (ka.length > maxObjectKeys) return void 0;
279
+ for (const k of ka) {
280
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
281
+ if (!Object.is(a[k], b[k])) return false;
282
+ }
283
+ return true;
284
+ }
285
+ function snap(value, config) {
286
+ return inspect(value, config.inspection);
287
+ }
288
+ function safeSource(fn) {
289
+ try {
290
+ return Function.prototype.toString.call(fn);
291
+ } catch {
292
+ return "";
293
+ }
294
+ }
295
+
296
+ // src/core/diagnose.ts
297
+ function severityFor(duration, t) {
298
+ if (duration >= t.critical) return "critical";
299
+ if (duration >= t.verySlow) return "very-slow";
300
+ if (duration >= t.slow) return "slow";
301
+ if (duration >= t.monitor) return "monitor";
302
+ return "normal";
303
+ }
304
+ function diagnose(input, thresholds) {
305
+ const severity = severityFor(input.selfDuration, thresholds);
306
+ const d = classify(input);
307
+ d.severity = severity;
308
+ if (!input.committed) {
309
+ d.evidence.unshift(
310
+ "This render attempt was not committed \u2014 React discarded it (concurrent interruption or a development replay). It is excluded from statistics."
311
+ );
312
+ } else if (input.attempts > 1) {
313
+ d.evidence.push(
314
+ `Render function ran ${input.attempts}\xD7 for one commit \u2014 a development replay (StrictMode double-invoke or a discarded attempt). Not production behaviour.`
315
+ );
316
+ }
317
+ if (severity === "critical" || severity === "very-slow") {
318
+ d.evidence.push(`Render cost ${fmt(input.selfDuration)} \u2014 above the ${thresholds.verySlow}ms threshold.`);
319
+ }
320
+ return d;
321
+ }
322
+ function classify(input) {
323
+ const { changedProps, contextChanges, parentRendered, parentUnknown, parentName } = input;
324
+ if (input.phase === "mount") {
325
+ return make("mount", "high", `${input.componentName} mounted.`, ["First render of this instance."], false);
326
+ }
327
+ if (input.trackedState.length > 0) {
328
+ const names = input.trackedState.map((s) => s.name);
329
+ const evidence = input.trackedState.map(
330
+ (s) => `\`${s.name}\`: ${formatInspected(s.previous)} \u2192 ${formatInspected(s.current)}`
331
+ );
332
+ if (changedProps.length > 0) {
333
+ evidence.push(`Props also changed: ${changedProps.map((c) => c.key).join(", ")}.`);
334
+ }
335
+ return make(
336
+ "state",
337
+ "high",
338
+ `${input.componentName} rendered because its own state changed: ${names.join(", ")}.`,
339
+ evidence,
340
+ false
341
+ );
342
+ }
343
+ if (changedProps.length > 0) {
344
+ const meaningful = changedProps.filter((c) => c.kind !== "reference");
345
+ const referenceOnly = changedProps.filter((c) => c.kind === "reference");
346
+ if (meaningful.length > 0) {
347
+ const keys2 = meaningful.map((c) => c.key);
348
+ const evidence2 = [
349
+ `Props changed with new values: ${keys2.join(", ")}.`,
350
+ ...meaningful.map(describe)
351
+ ];
352
+ if (referenceOnly.length > 0) {
353
+ evidence2.push(
354
+ `Also changed by reference only (contents identical): ${referenceOnly.map((c) => c.key).join(", ")}.`
355
+ );
356
+ }
357
+ return make(
358
+ "props",
359
+ "high",
360
+ `${input.componentName} rendered because ${plural(keys2.length, "prop")} changed: ${keys2.join(", ")}.`,
361
+ evidence2,
362
+ false,
363
+ referenceOnly.length > 0 ? stabiliseSuggestion(referenceOnly, parentName) : void 0
364
+ );
365
+ }
366
+ const keys = referenceOnly.map((c) => c.key);
367
+ const evidence = [
368
+ `${plural(keys.length, "prop")} changed by reference only: ${keys.join(", ")}.`,
369
+ ...referenceOnly.map(describe)
370
+ ];
371
+ if (parentRendered && parentName) {
372
+ evidence.push(`${parentName} re-rendered in the same commit and recreated ${keys.length > 1 ? "these values" : `\`${keys[0]}\``}.`);
373
+ }
374
+ const confidence = referenceOnly.some((c) => c.shallowEqual === true || c.valueType === "function") ? "medium" : "low";
375
+ return make(
376
+ "props",
377
+ confidence,
378
+ `${input.componentName} rendered because ${keys.join(", ")} changed by reference \u2014 the contents did not.`,
379
+ evidence,
380
+ true,
381
+ stabiliseSuggestion(referenceOnly, parentName)
382
+ );
383
+ }
384
+ if (input.propsReevaluated === false) {
385
+ if (contextChanges.length > 0) {
386
+ const c = contextChanges[0];
387
+ const detail = c.changedKeys.length > 0 ? ` Changed: ${c.changedKeys.join(", ")}.` : "";
388
+ return make(
389
+ "context",
390
+ "medium",
391
+ `${input.componentName} rendered after ${c.contextName} updated in the same commit.${detail}`,
392
+ [
393
+ "No new props came from above \u2014 the render started at this component.",
394
+ `Tracked context ${c.contextName} changed in this commit.`,
395
+ c.referenceOnly ? `${c.contextName}'s value changed by reference only \u2014 its contents are identical.` : `${c.contextName}'s contents changed.`,
396
+ "React does not expose which contexts a component subscribes to, so this is correlation within one commit, not a recorded subscription."
397
+ ],
398
+ c.referenceOnly,
399
+ c.referenceOnly ? `${c.contextName}'s provider recreates its value every render. Stabilise it with useMemo if consumers are doing real work.` : void 0
400
+ );
401
+ }
402
+ return make(
403
+ "state-or-external",
404
+ input.selfRenderProven ? "high" : "medium",
405
+ `${input.componentName} rendered from inside itself \u2014 local state, a store subscription, or a forced update.`,
406
+ [
407
+ "No new props came from above: the wrapper did not re-render.",
408
+ input.selfRenderProven ? "An instrumented child re-rendered from above in this commit, which proves this component produced it." : "No instrumented descendant rendered in this commit, so an uninstrumented descendant could in principle be the origin instead.",
409
+ "React does not expose hook state without private internals, so the exact source is not observable. Use useTrackedState to name it."
410
+ ],
411
+ false
412
+ );
413
+ }
414
+ if (input.propsReevaluated === true) {
415
+ const known = parentRendered && parentName;
416
+ return make(
417
+ "parent",
418
+ known && contextChanges.length === 0 ? "high" : "medium",
419
+ `${input.componentName} rendered because its parent rendered \u2014 its own inputs did not change.`,
420
+ [
421
+ known ? `${parentName} re-rendered in this commit.` : "Something above re-rendered this component, but the nearest instrumented ancestor did not \u2014 an uninstrumented component sits in between.",
422
+ "Every prop is identical by reference.",
423
+ contextChanges.length === 0 ? "No tracked context changed in this commit." : `A tracked context also changed (${contextChanges.map((c) => c.contextName).join(", ")}) \u2014 but new props arrived from above, so propagation is the direct cause.`
424
+ ],
425
+ true,
426
+ memoSuggestion(input)
427
+ );
428
+ }
429
+ if (parentRendered) {
430
+ const evidence = [
431
+ `${parentName ?? "The nearest instrumented ancestor"} re-rendered in this commit.`,
432
+ "Every prop is identical by reference.",
433
+ contextChanges.length === 0 ? "No tracked context changed in this commit." : `A tracked context also changed (${contextChanges.map((c) => c.contextName).join(", ")}) \u2014 if this component consumes it, that is an alternative cause.`
434
+ ];
435
+ return make(
436
+ "parent",
437
+ contextChanges.length === 0 ? "high" : "medium",
438
+ `${input.componentName} rendered because its parent rendered \u2014 its own inputs did not change.`,
439
+ evidence,
440
+ true,
441
+ memoSuggestion(input)
442
+ );
443
+ }
444
+ if (contextChanges.length > 0) {
445
+ const c = contextChanges[0];
446
+ const detail = c.changedKeys.length > 0 ? ` Changed: ${c.changedKeys.join(", ")}.` : "";
447
+ return make(
448
+ "context",
449
+ "medium",
450
+ `${input.componentName} rendered after ${c.contextName} updated in the same commit.${detail}`,
451
+ [
452
+ `Props are identical and the parent did not re-render.`,
453
+ `Tracked context ${c.contextName} changed in this commit.`,
454
+ c.referenceOnly ? `${c.contextName}'s value changed by reference only \u2014 its contents are identical.` : `${c.contextName}'s contents changed.`,
455
+ "React does not expose which contexts a component subscribes to, so this is correlation within one commit, not a recorded subscription."
456
+ ],
457
+ c.referenceOnly,
458
+ c.referenceOnly ? `${c.contextName}'s provider recreates its value every render. Stabilise it with useMemo if consumers are doing real work.` : void 0
459
+ );
460
+ }
461
+ if (parentUnknown) {
462
+ return make(
463
+ "unknown",
464
+ "low",
465
+ `Cause could not be determined reliably for ${input.componentName}.`,
466
+ [
467
+ "Props are identical and no tracked context changed.",
468
+ "This component has no instrumented ancestor, so a parent-propagated render cannot be ruled out.",
469
+ "Instrument the parent, or track the context it consumes, to narrow this down."
470
+ ],
471
+ false
472
+ );
473
+ }
474
+ return make(
475
+ "state-or-external",
476
+ "medium",
477
+ `${input.componentName} rendered from inside itself \u2014 local state, a store subscription, or a forced update.`,
478
+ [
479
+ "Props are identical by reference.",
480
+ "The nearest instrumented ancestor did not re-render in this commit.",
481
+ "No tracked context changed in this commit.",
482
+ "React does not expose hook state without private internals, so the exact source is not observable. Use useTrackedState to name it."
483
+ ],
484
+ false
485
+ );
486
+ }
487
+ function memoSuggestion(input) {
488
+ const worth = input.selfDuration >= 1 || input.priorAvoidableRenders >= 5;
489
+ return worth ? `Check whether ${input.componentName} benefits from React.memo(). It has re-rendered with identical props ${input.priorAvoidableRenders + 1}\xD7 at ${fmt(input.selfDuration)} each \u2014 measure before and after.` : `Props are identical, but this render costs ${fmt(input.selfDuration)}. Memoizing is unlikely to pay for itself yet.`;
490
+ }
491
+ function stabiliseSuggestion(changes, parentName) {
492
+ const fns = changes.filter((c) => c.valueType === "function").map((c) => c.key);
493
+ const objs = changes.filter((c) => c.valueType === "object" || c.valueType === "array").map((c) => c.key);
494
+ const where = parentName ? ` in ${parentName}` : "";
495
+ const parts = [];
496
+ if (fns.length > 0) parts.push(`${list(fns)} ${plural(fns.length, "is", "are")} recreated on every render${where} \u2014 useCallback, or hoist it, if a memoized child depends on it`);
497
+ if (objs.length > 0) parts.push(`${list(objs)} ${plural(objs.length, "is", "are")} a new object each render${where} \u2014 useMemo it, or pass the primitive fields you actually use`);
498
+ if (parts.length === 0) return `Find where ${list(changes.map((c) => c.key))} is created${where} and stabilise it.`;
499
+ return `${capitalize(parts.join("; "))}.`;
500
+ }
501
+ function describe(c) {
502
+ switch (c.kind) {
503
+ case "added":
504
+ return `\`${c.key}\` was added.`;
505
+ case "removed":
506
+ return `\`${c.key}\` was removed.`;
507
+ case "reference":
508
+ if (c.valueType === "function") {
509
+ return c.sourceEqual ? `\`${c.key}\` is a new function with identical source \u2014 an inline closure recreated by the parent.` : `\`${c.key}\` is a new function reference.`;
510
+ }
511
+ if (c.shallowEqual === true) return `\`${c.key}\` is a new ${c.valueType} whose shallow contents are identical.`;
512
+ return `\`${c.key}\` changed reference; contents were too large or too exotic to compare cheaply.`;
513
+ case "value":
514
+ return `\`${c.key}\` changed value.`;
515
+ }
516
+ }
517
+ function make(reason, confidence, summary, evidence, potentiallyAvoidable, suggestion) {
518
+ return { reason, confidence, summary, evidence, potentiallyAvoidable, suggestion, severity: "normal" };
519
+ }
520
+ var fmt = (ms) => `${ms.toFixed(1)}ms`;
521
+ var list = (xs) => xs.map((x) => `\`${x}\``).join(", ");
522
+ var capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
523
+ function plural(n, one, many) {
524
+ if (one === "is") return n === 1 ? "is" : many;
525
+ return n === 1 ? `${one}` : `${one}s`;
526
+ }
527
+
528
+ // src/core/ringBuffer.ts
529
+ var RingBuffer = class {
530
+ constructor(capacity) {
531
+ this.head = 0;
532
+ this.count = 0;
533
+ this.items = new Array(Math.max(1, capacity));
534
+ }
535
+ push(item) {
536
+ this.items[this.head] = item;
537
+ this.head = (this.head + 1) % this.items.length;
538
+ if (this.count < this.items.length) this.count++;
539
+ }
540
+ get size() {
541
+ return this.count;
542
+ }
543
+ /** Oldest → newest. */
544
+ toArray() {
545
+ const out = [];
546
+ const len = this.items.length;
547
+ const start = (this.head - this.count + len) % len;
548
+ for (let i = 0; i < this.count; i++) {
549
+ const v = this.items[(start + i) % len];
550
+ if (v !== void 0) out.push(v);
551
+ }
552
+ return out;
553
+ }
554
+ clear() {
555
+ this.items = new Array(this.items.length);
556
+ this.head = 0;
557
+ this.count = 0;
558
+ }
559
+ resize(capacity) {
560
+ const existing = this.toArray();
561
+ this.items = new Array(Math.max(1, capacity));
562
+ this.head = 0;
563
+ this.count = 0;
564
+ const keep = existing.slice(Math.max(0, existing.length - this.items.length));
565
+ for (const item of keep) this.push(item);
566
+ }
567
+ };
568
+
569
+ // src/core/store.ts
570
+ var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
571
+ var EMPTY_STATE = [];
572
+ var DURATION_SAMPLE = 200;
573
+ var SWEEP_DELAY_MS = 250;
574
+ var emptyReasons = () => ({
575
+ mount: 0,
576
+ props: 0,
577
+ state: 0,
578
+ parent: 0,
579
+ context: 0,
580
+ "state-or-external": 0,
581
+ unknown: 0
582
+ });
583
+ var Detective = class {
584
+ constructor() {
585
+ this.config = { ...defaultConfig };
586
+ this.nodes = /* @__PURE__ */ new Map();
587
+ this.listeners = /* @__PURE__ */ new Set();
588
+ this.pending = [];
589
+ this.contextChanges = [];
590
+ /** Orders renders and context updates so they can be matched without timers. */
591
+ this.seq = 0;
592
+ this.flushScheduled = false;
593
+ this.nextId = 0;
594
+ /** Nodes created by `useRenderDiagnostics`, where props/state cannot be separated. */
595
+ this.hookModeNodes = /* @__PURE__ */ new WeakSet();
596
+ this.initialized = false;
597
+ this.events = new RingBuffer(this.config.maxEvents);
598
+ }
599
+ /** Idempotent: repeated calls reconfigure, they never duplicate anything (§47). */
600
+ init(options = {}) {
601
+ this.configure(options);
602
+ this.initialized = true;
603
+ return this;
604
+ }
605
+ configure(options = {}) {
606
+ const previousMax = this.config.maxEvents;
607
+ this.config = mergeConfig(this.config, options);
608
+ if (this.config.maxEvents !== previousMax) this.events.resize(this.config.maxEvents);
609
+ }
610
+ get isInitialized() {
611
+ return this.initialized;
612
+ }
613
+ get enabled() {
614
+ return this.config.enabled;
615
+ }
616
+ subscribe(listener) {
617
+ this.listeners.add(listener);
618
+ return () => {
619
+ this.listeners.delete(listener);
620
+ };
621
+ }
622
+ // ---------------------------------------------------------------- registry
623
+ /**
624
+ * Creates a node without publishing it. StrictMode's double mount renders
625
+ * discards one of the two, and only the surviving fiber's effect attaches —
626
+ * so discarded nodes are simply garbage collected.
627
+ */
628
+ createNode(name, parent) {
629
+ if (!shouldInstrument(name, this.config)) return void 0;
630
+ const sampled = this.config.samplingRate >= 1 || Math.random() < this.config.samplingRate;
631
+ const node = {
632
+ id: `rrd_${++this.nextId}`,
633
+ name,
634
+ parent,
635
+ depth: parent ? parent.depth + 1 : 0,
636
+ sampled,
637
+ attempts: 0,
638
+ pendingState: [],
639
+ seenCommit: false,
640
+ renderNumber: 0,
641
+ lastCommitTime: -1,
642
+ durations: [],
643
+ stats: {
644
+ renderCount: 0,
645
+ mountCount: 0,
646
+ uncommittedAttempts: 0,
647
+ devReplays: 0,
648
+ totalSelfDuration: 0,
649
+ maxSelfDuration: 0,
650
+ slowRenders: 0,
651
+ potentiallyAvoidableRenders: 0,
652
+ reasons: emptyReasons()
653
+ }
654
+ };
655
+ return node;
656
+ }
657
+ attach(node) {
658
+ this.nodes.set(node.id, node);
659
+ }
660
+ detach(node) {
661
+ node.prevProps = void 0;
662
+ node.pendingProps = void 0;
663
+ this.nodes.delete(node.id);
664
+ }
665
+ /** Hot path. Must stay allocation-free and O(1). */
666
+ recordAttempt(node, props) {
667
+ node.attempts++;
668
+ node.pendingProps = props;
669
+ }
670
+ recordStateChange(node, change) {
671
+ if (node.pendingState.length < 16) node.pendingState.push(change);
672
+ }
673
+ /**
674
+ * Hot path. Called from Profiler#onRender; only enqueues.
675
+ *
676
+ * `attempts` is the number of times the *wrapper* rendered, i.e. how often
677
+ * this component's props were re-evaluated from above. Zero means the render
678
+ * originated at or below this component — the flush pass works out which.
679
+ */
680
+ recordCommit(node, commit) {
681
+ this.pending.push({
682
+ ...commit,
683
+ node,
684
+ attempts: node.attempts,
685
+ props: node.pendingProps,
686
+ state: node.pendingState.length > 0 ? node.pendingState : EMPTY_STATE,
687
+ seq: ++this.seq
688
+ });
689
+ node.seenCommit = true;
690
+ node.attempts = 0;
691
+ node.pendingProps = void 0;
692
+ if (node.pendingState.length > 0) node.pendingState = [];
693
+ node.lastCommitTime = commit.commitTime;
694
+ this.scheduleFlush();
695
+ }
696
+ recordContextChange(change) {
697
+ this.contextChanges.push({ change, seq: ++this.seq });
698
+ this.scheduleFlush();
699
+ }
700
+ // ------------------------------------------------------------ deferred work
701
+ scheduleFlush() {
702
+ if (this.flushScheduled) return;
703
+ this.flushScheduled = true;
704
+ queueMicrotask(() => {
705
+ this.flushScheduled = false;
706
+ try {
707
+ this.flush();
708
+ } catch {
709
+ }
710
+ });
711
+ this.scheduleSweep();
712
+ }
713
+ scheduleSweep() {
714
+ if (this.sweepHandle !== void 0) return;
715
+ this.sweepHandle = setTimeout(() => {
716
+ this.sweepHandle = void 0;
717
+ try {
718
+ this.sweep();
719
+ } catch {
720
+ }
721
+ }, SWEEP_DELAY_MS);
722
+ this.sweepHandle.unref?.();
723
+ }
724
+ /** Attempts that never reached a commit were abandoned or replayed. */
725
+ sweep() {
726
+ for (const node of this.nodes.values()) {
727
+ if (node.attempts > 0) {
728
+ node.stats.uncommittedAttempts += node.attempts;
729
+ node.attempts = 0;
730
+ }
731
+ }
732
+ }
733
+ /** Runs after the commit, off the render path. Whole batch is available here. */
734
+ flush() {
735
+ if (this.pending.length === 0) {
736
+ this.contextChanges.length = 0;
737
+ return;
738
+ }
739
+ const batch = this.pending;
740
+ this.pending = [];
741
+ const pendingContexts = this.contextChanges;
742
+ this.contextChanges = [];
743
+ const hookMode = this.hookModeNodes;
744
+ let nextReal = -1;
745
+ for (let i = batch.length - 1; i >= 0; i--) {
746
+ const rec = batch[i];
747
+ if (rec.commitTime >= 0) nextReal = rec.commitTime;
748
+ else if (nextReal >= 0) rec.commitTime = nextReal;
749
+ else rec.commitTime = now();
750
+ }
751
+ const contexts = pendingContexts.map(({ change, seq }) => {
752
+ if (change.commitTime >= 0) return change;
753
+ const following = batch.find((r) => r.seq > seq);
754
+ return { ...change, commitTime: following ? following.commitTime : batch[batch.length - 1]?.commitTime ?? now() };
755
+ });
756
+ const commitMembers = /* @__PURE__ */ new Map();
757
+ const descendantTime = /* @__PURE__ */ new Map();
758
+ for (const rec of batch) {
759
+ let set = commitMembers.get(rec.commitTime);
760
+ if (!set) commitMembers.set(rec.commitTime, set = /* @__PURE__ */ new Set());
761
+ set.add(rec.node);
762
+ }
763
+ for (const rec of batch) {
764
+ const parent = rec.node.parent;
765
+ if (!parent) continue;
766
+ if (!commitMembers.get(rec.commitTime)?.has(parent)) continue;
767
+ descendantTime.set(parent, (descendantTime.get(parent) ?? 0) + rec.subtreeDuration);
768
+ }
769
+ const provenSelfRender = /* @__PURE__ */ new Set();
770
+ const hasInstrumentedDescendant = /* @__PURE__ */ new Set();
771
+ for (const rec of batch) {
772
+ const members = commitMembers.get(rec.commitTime);
773
+ if (rec.attempts > 0 && rec.node.parent && members?.has(rec.node.parent)) {
774
+ provenSelfRender.add(rec.node.parent);
775
+ }
776
+ for (let a = rec.node.parent; a; a = a.parent) {
777
+ if (members?.has(a)) hasInstrumentedDescendant.add(a);
778
+ }
779
+ }
780
+ for (const rec of batch) {
781
+ if (!rec.node.sampled) continue;
782
+ const counted = rec.attempts > 0 || rec.phase === "mount" || provenSelfRender.has(rec.node) || !hasInstrumentedDescendant.has(rec.node);
783
+ if (!counted) continue;
784
+ try {
785
+ this.emit(
786
+ this.buildEvent(rec.node, rec, commitMembers, descendantTime, contexts, provenSelfRender, hookMode.has(rec.node))
787
+ );
788
+ } catch {
789
+ }
790
+ }
791
+ }
792
+ buildEvent(node, rec, commitMembers, descendantTime, contexts, provenSelfRender, isHookMode) {
793
+ const parent = node.parent;
794
+ const parentRendered = parent ? commitMembers.get(rec.commitTime)?.has(parent) === true : false;
795
+ const propsReevaluated = isHookMode ? void 0 : rec.attempts > 0;
796
+ const isMount = rec.phase === "mount";
797
+ const props = rec.props ?? node.prevProps ?? {};
798
+ const { changed, unchanged } = isMount || propsReevaluated === false ? { changed: [], unchanged: Object.keys(props) } : diffProps(node.prevProps, props, this.config);
799
+ node.prevProps = props;
800
+ const accounted = descendantTime.get(node) ?? 0;
801
+ const selfDuration = Math.max(0, rec.subtreeDuration - accounted);
802
+ const relevantContexts = contexts.filter((c) => c.commitTime === rec.commitTime);
803
+ const trackedState = rec.state;
804
+ node.renderNumber++;
805
+ const diagnosis = diagnose(
806
+ {
807
+ componentName: node.name,
808
+ phase: rec.phase,
809
+ parentName: parent?.name,
810
+ parentRendered,
811
+ parentUnknown: !parent,
812
+ propsReevaluated,
813
+ selfRenderProven: provenSelfRender.has(node),
814
+ changedProps: changed,
815
+ contextChanges: relevantContexts,
816
+ selfDuration,
817
+ attempts: rec.attempts,
818
+ committed: true,
819
+ trackedState,
820
+ priorAvoidableRenders: node.stats.potentiallyAvoidableRenders
821
+ },
822
+ this.config.thresholds
823
+ );
824
+ const componentInfo = {
825
+ id: node.id,
826
+ name: node.name,
827
+ parentId: parent?.id,
828
+ depth: node.depth
829
+ };
830
+ const event = {
831
+ id: `${node.id}#${node.renderNumber}`,
832
+ component: componentInfo,
833
+ timestamp: rec.startTime,
834
+ renderNumber: node.renderNumber,
835
+ phase: rec.phase,
836
+ timings: {
837
+ subtreeDuration: rec.subtreeDuration,
838
+ baseDuration: rec.baseDuration,
839
+ selfDuration,
840
+ accountedDescendantDuration: accounted,
841
+ commitTime: rec.commitTime,
842
+ startTime: rec.startTime
843
+ },
844
+ changedProps: changed,
845
+ unchangedProps: unchanged,
846
+ parent: parent ? { id: parent.id, name: parent.name, parentId: parent.parent?.id, depth: parent.depth } : void 0,
847
+ parentRendered,
848
+ selfOriginated: propsReevaluated === false,
849
+ contextChanges: relevantContexts,
850
+ trackedState,
851
+ committed: true,
852
+ attempts: Math.max(1, rec.attempts),
853
+ devReplay: rec.attempts > 1,
854
+ diagnosis
855
+ };
856
+ this.updateStats(node, event);
857
+ return event;
858
+ }
859
+ updateStats(node, event) {
860
+ const s = node.stats;
861
+ s.renderCount++;
862
+ if (event.phase === "mount") s.mountCount++;
863
+ if (event.devReplay) s.devReplays++;
864
+ const d = event.timings.selfDuration;
865
+ s.totalSelfDuration += d;
866
+ if (d > s.maxSelfDuration) s.maxSelfDuration = d;
867
+ if (d >= this.config.slowRenderThreshold) s.slowRenders++;
868
+ if (event.diagnosis.potentiallyAvoidable) s.potentiallyAvoidableRenders++;
869
+ s.reasons[event.diagnosis.reason]++;
870
+ node.durations.push(d);
871
+ if (node.durations.length > DURATION_SAMPLE) node.durations.shift();
872
+ }
873
+ emit(event) {
874
+ this.events.push(event);
875
+ const { onEvent } = this.config;
876
+ if (onEvent) {
877
+ try {
878
+ onEvent(event);
879
+ } catch {
880
+ }
881
+ }
882
+ for (const listener of this.listeners) {
883
+ try {
884
+ listener(event);
885
+ } catch {
886
+ }
887
+ }
888
+ }
889
+ // -------------------------------------------------------------------- query
890
+ getEvents() {
891
+ this.flush();
892
+ return this.events.toArray();
893
+ }
894
+ getComponentStats(name) {
895
+ this.flush();
896
+ const out = [];
897
+ for (const node of this.nodes.values()) {
898
+ if (name && node.name !== name) continue;
899
+ if (node.stats.renderCount === 0 && node.stats.uncommittedAttempts === 0) continue;
900
+ out.push(toStats(node));
901
+ }
902
+ return out;
903
+ }
904
+ getStats() {
905
+ const all = this.getComponentStats();
906
+ const byName = /* @__PURE__ */ new Map();
907
+ for (const s of all) {
908
+ const existing = byName.get(s.name);
909
+ byName.set(s.name, existing ? mergeStats(existing, s) : s);
910
+ }
911
+ const merged = [...byName.values()];
912
+ return {
913
+ components: merged.length,
914
+ totalRenders: merged.reduce((a, s) => a + s.renderCount, 0),
915
+ totalRenderTime: merged.reduce((a, s) => a + s.totalSelfDuration, 0),
916
+ slowRenders: merged.reduce((a, s) => a + s.slowRenders, 0),
917
+ potentiallyAvoidableRenders: merged.reduce((a, s) => a + s.potentiallyAvoidableRenders, 0),
918
+ devReplays: merged.reduce((a, s) => a + s.devReplays, 0),
919
+ slowest: [...merged].sort((a, b) => b.maxSelfDuration - a.maxSelfDuration).slice(0, 10),
920
+ mostRendered: [...merged].sort((a, b) => b.renderCount - a.renderCount).slice(0, 10),
921
+ mostExpensive: [...merged].sort((a, b) => b.totalSelfDuration - a.totalSelfDuration).slice(0, 10)
922
+ };
923
+ }
924
+ clear() {
925
+ this.events.clear();
926
+ this.pending.length = 0;
927
+ this.contextChanges.length = 0;
928
+ for (const node of this.nodes.values()) {
929
+ node.stats = {
930
+ renderCount: 0,
931
+ mountCount: 0,
932
+ uncommittedAttempts: 0,
933
+ devReplays: 0,
934
+ totalSelfDuration: 0,
935
+ maxSelfDuration: 0,
936
+ slowRenders: 0,
937
+ potentiallyAvoidableRenders: 0,
938
+ reasons: emptyReasons()
939
+ };
940
+ node.durations.length = 0;
941
+ node.renderNumber = 0;
942
+ }
943
+ }
944
+ /** Full teardown — used by tests and by HMR disposal. */
945
+ reset() {
946
+ this.clear();
947
+ this.nodes.clear();
948
+ this.listeners.clear();
949
+ if (this.sweepHandle !== void 0) clearTimeout(this.sweepHandle);
950
+ this.sweepHandle = void 0;
951
+ this.config = { ...defaultConfig };
952
+ this.events.resize(this.config.maxEvents);
953
+ this.initialized = false;
954
+ }
955
+ };
956
+ function percentile(sorted, p) {
957
+ if (sorted.length === 0) return 0;
958
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
959
+ return sorted[idx];
960
+ }
961
+ function toStats(node) {
962
+ const sorted = [...node.durations].sort((a, b) => a - b);
963
+ const s = node.stats;
964
+ return {
965
+ id: node.id,
966
+ name: node.name,
967
+ renderCount: s.renderCount,
968
+ mountCount: s.mountCount,
969
+ uncommittedAttempts: s.uncommittedAttempts,
970
+ devReplays: s.devReplays,
971
+ totalSelfDuration: s.totalSelfDuration,
972
+ averageSelfDuration: s.renderCount ? s.totalSelfDuration / s.renderCount : 0,
973
+ medianSelfDuration: percentile(sorted, 50),
974
+ p95SelfDuration: percentile(sorted, 95),
975
+ p99SelfDuration: percentile(sorted, 99),
976
+ maxSelfDuration: s.maxSelfDuration,
977
+ slowRenders: s.slowRenders,
978
+ potentiallyAvoidableRenders: s.potentiallyAvoidableRenders,
979
+ reasons: { ...s.reasons }
980
+ };
981
+ }
982
+ function mergeStats(a, b) {
983
+ const renderCount = a.renderCount + b.renderCount;
984
+ const total = a.totalSelfDuration + b.totalSelfDuration;
985
+ const reasons = { ...a.reasons };
986
+ for (const key of Object.keys(b.reasons)) reasons[key] += b.reasons[key];
987
+ return {
988
+ id: a.id,
989
+ name: a.name,
990
+ renderCount,
991
+ mountCount: a.mountCount + b.mountCount,
992
+ uncommittedAttempts: a.uncommittedAttempts + b.uncommittedAttempts,
993
+ devReplays: a.devReplays + b.devReplays,
994
+ totalSelfDuration: total,
995
+ averageSelfDuration: renderCount ? total / renderCount : 0,
996
+ medianSelfDuration: Math.max(a.medianSelfDuration, b.medianSelfDuration),
997
+ p95SelfDuration: Math.max(a.p95SelfDuration, b.p95SelfDuration),
998
+ p99SelfDuration: Math.max(a.p99SelfDuration, b.p99SelfDuration),
999
+ maxSelfDuration: Math.max(a.maxSelfDuration, b.maxSelfDuration),
1000
+ slowRenders: a.slowRenders + b.slowRenders,
1001
+ potentiallyAvoidableRenders: a.potentiallyAvoidableRenders + b.potentiallyAvoidableRenders,
1002
+ reasons
1003
+ };
1004
+ }
1005
+ var KEY = /* @__PURE__ */ Symbol.for("react-render-detective.instance");
1006
+ function getDetective() {
1007
+ const g = globalThis;
1008
+ if (!g[KEY]) g[KEY] = new Detective();
1009
+ return g[KEY];
1010
+ }
1011
+
1012
+ exports.Detective = Detective;
1013
+ exports.RingBuffer = RingBuffer;
1014
+ exports.defaultConfig = defaultConfig;
1015
+ exports.detectDev = detectDev;
1016
+ exports.diagnose = diagnose;
1017
+ exports.diffProps = diffProps;
1018
+ exports.formatInspected = formatInspected;
1019
+ exports.getDetective = getDetective;
1020
+ exports.inspect = inspect;
1021
+ exports.isPlainObject = isPlainObject;
1022
+ exports.isReactElement = isReactElement;
1023
+ exports.matches = matches;
1024
+ exports.mergeConfig = mergeConfig;
1025
+ exports.severityFor = severityFor;
1026
+ exports.shallowEqual = shallowEqual;
1027
+ exports.shouldInstrument = shouldInstrument;
1028
+ exports.valueType = valueType;
1029
+ //# sourceMappingURL=chunk-7FTXUOB6.cjs.map
1030
+ //# sourceMappingURL=chunk-7FTXUOB6.cjs.map