react-render-detective 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/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/index.cjs +314 -0
- 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 +303 -2
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +20 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +62 -0
- package/dist/testing.d.ts +62 -0
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[Guide](docs/GUIDE.md) · [API](docs/API.md) · [Feasibility report](docs/FEASIBILITY.md) ·
|
|
12
12
|
[Benchmarks](docs/BENCHMARKS.md)
|
|
13
13
|
|
|
14
|
-
> **Status: 0.
|
|
14
|
+
> **Status: 0.4.0, early release.** 76 tests pass on React 18 and 19; benchmarks and bundle budgets
|
|
15
15
|
> are green; the packed package is verified in a clean install for ESM, CJS and TypeScript
|
|
16
16
|
> consumers; and the demo dashboard has been driven end to end in Chrome, which found four real
|
|
17
17
|
> defects the jsdom suite had missed (see the [changelog](CHANGELOG.md)). Not yet exercised:
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/testing/index.ts
|
|
4
|
+
function profileFromEvents(scenario, events, remounts = {}) {
|
|
5
|
+
const components = {};
|
|
6
|
+
for (const event of events) {
|
|
7
|
+
const name = event.component.name;
|
|
8
|
+
const entry = components[name] ?? (components[name] = { renders: 0, remounts: remounts[name] ?? 0, avoidableRenders: 0 });
|
|
9
|
+
entry.renders++;
|
|
10
|
+
if (event.diagnosis.potentiallyAvoidable) entry.avoidableRenders++;
|
|
11
|
+
}
|
|
12
|
+
return { scenario, components };
|
|
13
|
+
}
|
|
14
|
+
var METRICS = ["renders", "remounts", "avoidableRenders"];
|
|
15
|
+
function compareProfiles(baseline, current, options = {}) {
|
|
16
|
+
const { tolerance = 0, ignoreBelow = 0, ignore = [], failOnImprovement = false } = options;
|
|
17
|
+
const regressions = [];
|
|
18
|
+
const improvements = [];
|
|
19
|
+
const added = [];
|
|
20
|
+
for (const [component, currentProfile] of Object.entries(current.components)) {
|
|
21
|
+
if (ignore.includes(component)) continue;
|
|
22
|
+
const baseProfile = baseline.components[component];
|
|
23
|
+
if (!baseProfile) {
|
|
24
|
+
added.push(component);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
for (const metric of METRICS) {
|
|
28
|
+
const before = baseProfile[metric];
|
|
29
|
+
const after = currentProfile[metric];
|
|
30
|
+
if (before < ignoreBelow && after < ignoreBelow) continue;
|
|
31
|
+
const allowed = before + Math.max(before * tolerance, 0);
|
|
32
|
+
if (after > allowed) {
|
|
33
|
+
regressions.push({ component, metric, baseline: before, current: after, delta: after - before });
|
|
34
|
+
} else if (after < before) {
|
|
35
|
+
improvements.push({ component, metric, baseline: before, current: after, delta: after - before });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const ok = regressions.length === 0 && (!failOnImprovement || improvements.length === 0);
|
|
40
|
+
return { ok, scenario: current.scenario, regressions, improvements, added, message: describe(current.scenario, regressions, improvements, added) };
|
|
41
|
+
}
|
|
42
|
+
function describe(scenario, regressions, improvements, added) {
|
|
43
|
+
if (regressions.length === 0) {
|
|
44
|
+
const parts = [`No render regressions in "${scenario}".`];
|
|
45
|
+
if (improvements.length > 0) {
|
|
46
|
+
parts.push(
|
|
47
|
+
`Improved: ${improvements.map((i) => `${i.component} ${i.metric} ${i.baseline}\u2192${i.current}`).join(", ")}.`,
|
|
48
|
+
"If these are intended, update the baseline so they cannot silently regress again."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (added.length > 0) parts.push(`New components not in the baseline: ${added.join(", ")}.`);
|
|
52
|
+
return parts.join("\n");
|
|
53
|
+
}
|
|
54
|
+
const lines = [`Render regressions in "${scenario}":`, ""];
|
|
55
|
+
for (const r of regressions) {
|
|
56
|
+
lines.push(` ${r.component} ${r.metric}: ${r.baseline} \u2192 ${r.current} (+${r.delta})`);
|
|
57
|
+
}
|
|
58
|
+
lines.push(
|
|
59
|
+
"",
|
|
60
|
+
"Each of these is a component doing more work than the baseline allows.",
|
|
61
|
+
"Run the scenario with the overlay or `explain()` to see which prop or parent is responsible,",
|
|
62
|
+
"or update the baseline if the change is intended."
|
|
63
|
+
);
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
function assertNoRenderRegressions(baseline, current, options) {
|
|
67
|
+
const result = compareProfiles(baseline, current, options);
|
|
68
|
+
if (!result.ok) throw new Error(result.message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
exports.assertNoRenderRegressions = assertNoRenderRegressions;
|
|
72
|
+
exports.compareProfiles = compareProfiles;
|
|
73
|
+
exports.profileFromEvents = profileFromEvents;
|
|
74
|
+
//# sourceMappingURL=chunk-H5RG2EPP.cjs.map
|
|
75
|
+
//# sourceMappingURL=chunk-H5RG2EPP.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/testing/index.ts"],"names":[],"mappings":"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","file":"chunk-H5RG2EPP.cjs","sourcesContent":["/**\n * Render-regression testing.\n *\n * Fixing a render problem once is easy; keeping it fixed is the hard part. This\n * turns render behaviour into something a pull request can fail on: record a\n * profile for a scripted interaction, commit it, and compare on every run.\n *\n * Deliberately assertion-library agnostic — it returns data and a message, and\n * your test framework decides what to do with it.\n */\nimport type { RenderEvent } from \"../core/types.js\";\n\nexport interface ComponentProfile {\n renders: number;\n remounts: number;\n avoidableRenders: number;\n}\n\nexport interface RenderProfile {\n /** Free-form label, e.g. \"search: type one character\". */\n scenario: string;\n components: Record<string, ComponentProfile>;\n}\n\nexport interface RegressionOptions {\n /**\n * Allowed growth before a component counts as regressed, as a fraction.\n * `0.2` tolerates a 20% increase. Defaults to 0 — exact.\n */\n tolerance?: number;\n /** Ignore components below this render count in the baseline. */\n ignoreBelow?: number;\n /** Component names to skip entirely. */\n ignore?: string[];\n /** Fail when a component is rendering *fewer* times too. Off by default. */\n failOnImprovement?: boolean;\n}\n\nexport interface Regression {\n component: string;\n metric: \"renders\" | \"remounts\" | \"avoidableRenders\";\n baseline: number;\n current: number;\n delta: number;\n}\n\nexport interface RegressionResult {\n ok: boolean;\n scenario: string;\n regressions: Regression[];\n improvements: Regression[];\n /** Components present now but absent from the baseline. */\n added: string[];\n message: string;\n}\n\n/** Builds a profile from recorded events. Pass `getEvents()`. */\nexport function profileFromEvents(scenario: string, events: RenderEvent[], remounts: Record<string, number> = {}): RenderProfile {\n const components: Record<string, ComponentProfile> = {};\n for (const event of events) {\n const name = event.component.name;\n const entry = (components[name] ??= { renders: 0, remounts: remounts[name] ?? 0, avoidableRenders: 0 });\n entry.renders++;\n if (event.diagnosis.potentiallyAvoidable) entry.avoidableRenders++;\n }\n return { scenario, components };\n}\n\nconst METRICS: Array<Regression[\"metric\"]> = [\"renders\", \"remounts\", \"avoidableRenders\"];\n\nexport function compareProfiles(\n baseline: RenderProfile,\n current: RenderProfile,\n options: RegressionOptions = {},\n): RegressionResult {\n const { tolerance = 0, ignoreBelow = 0, ignore = [], failOnImprovement = false } = options;\n const regressions: Regression[] = [];\n const improvements: Regression[] = [];\n const added: string[] = [];\n\n for (const [component, currentProfile] of Object.entries(current.components)) {\n if (ignore.includes(component)) continue;\n const baseProfile = baseline.components[component];\n if (!baseProfile) {\n added.push(component);\n continue;\n }\n\n for (const metric of METRICS) {\n const before = baseProfile[metric];\n const after = currentProfile[metric];\n if (before < ignoreBelow && after < ignoreBelow) continue;\n const allowed = before + Math.max(before * tolerance, 0);\n if (after > allowed) {\n regressions.push({ component, metric, baseline: before, current: after, delta: after - before });\n } else if (after < before) {\n improvements.push({ component, metric, baseline: before, current: after, delta: after - before });\n }\n }\n }\n\n const ok = regressions.length === 0 && (!failOnImprovement || improvements.length === 0);\n return { ok, scenario: current.scenario, regressions, improvements, added, message: describe(current.scenario, regressions, improvements, added) };\n}\n\nfunction describe(scenario: string, regressions: Regression[], improvements: Regression[], added: string[]): string {\n if (regressions.length === 0) {\n const parts = [`No render regressions in \"${scenario}\".`];\n if (improvements.length > 0) {\n parts.push(\n `Improved: ${improvements.map((i) => `${i.component} ${i.metric} ${i.baseline}→${i.current}`).join(\", \")}.`,\n \"If these are intended, update the baseline so they cannot silently regress again.\",\n );\n }\n if (added.length > 0) parts.push(`New components not in the baseline: ${added.join(\", \")}.`);\n return parts.join(\"\\n\");\n }\n\n const lines = [`Render regressions in \"${scenario}\":`, \"\"];\n for (const r of regressions) {\n lines.push(` ${r.component} ${r.metric}: ${r.baseline} → ${r.current} (+${r.delta})`);\n }\n lines.push(\n \"\",\n \"Each of these is a component doing more work than the baseline allows.\",\n \"Run the scenario with the overlay or `explain()` to see which prop or parent is responsible,\",\n \"or update the baseline if the change is intended.\",\n );\n return lines.join(\"\\n\");\n}\n\n/**\n * Throws when the current profile is worse than the baseline. The one-liner for\n * a test file; use `compareProfiles` when you want the data.\n */\nexport function assertNoRenderRegressions(\n baseline: RenderProfile,\n current: RenderProfile,\n options?: RegressionOptions,\n): void {\n const result = compareProfiles(baseline, current, options);\n if (!result.ok) throw new Error(result.message);\n}\n"]}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/testing/index.ts
|
|
2
|
+
function profileFromEvents(scenario, events, remounts = {}) {
|
|
3
|
+
const components = {};
|
|
4
|
+
for (const event of events) {
|
|
5
|
+
const name = event.component.name;
|
|
6
|
+
const entry = components[name] ?? (components[name] = { renders: 0, remounts: remounts[name] ?? 0, avoidableRenders: 0 });
|
|
7
|
+
entry.renders++;
|
|
8
|
+
if (event.diagnosis.potentiallyAvoidable) entry.avoidableRenders++;
|
|
9
|
+
}
|
|
10
|
+
return { scenario, components };
|
|
11
|
+
}
|
|
12
|
+
var METRICS = ["renders", "remounts", "avoidableRenders"];
|
|
13
|
+
function compareProfiles(baseline, current, options = {}) {
|
|
14
|
+
const { tolerance = 0, ignoreBelow = 0, ignore = [], failOnImprovement = false } = options;
|
|
15
|
+
const regressions = [];
|
|
16
|
+
const improvements = [];
|
|
17
|
+
const added = [];
|
|
18
|
+
for (const [component, currentProfile] of Object.entries(current.components)) {
|
|
19
|
+
if (ignore.includes(component)) continue;
|
|
20
|
+
const baseProfile = baseline.components[component];
|
|
21
|
+
if (!baseProfile) {
|
|
22
|
+
added.push(component);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
for (const metric of METRICS) {
|
|
26
|
+
const before = baseProfile[metric];
|
|
27
|
+
const after = currentProfile[metric];
|
|
28
|
+
if (before < ignoreBelow && after < ignoreBelow) continue;
|
|
29
|
+
const allowed = before + Math.max(before * tolerance, 0);
|
|
30
|
+
if (after > allowed) {
|
|
31
|
+
regressions.push({ component, metric, baseline: before, current: after, delta: after - before });
|
|
32
|
+
} else if (after < before) {
|
|
33
|
+
improvements.push({ component, metric, baseline: before, current: after, delta: after - before });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const ok = regressions.length === 0 && (!failOnImprovement || improvements.length === 0);
|
|
38
|
+
return { ok, scenario: current.scenario, regressions, improvements, added, message: describe(current.scenario, regressions, improvements, added) };
|
|
39
|
+
}
|
|
40
|
+
function describe(scenario, regressions, improvements, added) {
|
|
41
|
+
if (regressions.length === 0) {
|
|
42
|
+
const parts = [`No render regressions in "${scenario}".`];
|
|
43
|
+
if (improvements.length > 0) {
|
|
44
|
+
parts.push(
|
|
45
|
+
`Improved: ${improvements.map((i) => `${i.component} ${i.metric} ${i.baseline}\u2192${i.current}`).join(", ")}.`,
|
|
46
|
+
"If these are intended, update the baseline so they cannot silently regress again."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (added.length > 0) parts.push(`New components not in the baseline: ${added.join(", ")}.`);
|
|
50
|
+
return parts.join("\n");
|
|
51
|
+
}
|
|
52
|
+
const lines = [`Render regressions in "${scenario}":`, ""];
|
|
53
|
+
for (const r of regressions) {
|
|
54
|
+
lines.push(` ${r.component} ${r.metric}: ${r.baseline} \u2192 ${r.current} (+${r.delta})`);
|
|
55
|
+
}
|
|
56
|
+
lines.push(
|
|
57
|
+
"",
|
|
58
|
+
"Each of these is a component doing more work than the baseline allows.",
|
|
59
|
+
"Run the scenario with the overlay or `explain()` to see which prop or parent is responsible,",
|
|
60
|
+
"or update the baseline if the change is intended."
|
|
61
|
+
);
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
function assertNoRenderRegressions(baseline, current, options) {
|
|
65
|
+
const result = compareProfiles(baseline, current, options);
|
|
66
|
+
if (!result.ok) throw new Error(result.message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { assertNoRenderRegressions, compareProfiles, profileFromEvents };
|
|
70
|
+
//# sourceMappingURL=chunk-LILK23YH.js.map
|
|
71
|
+
//# sourceMappingURL=chunk-LILK23YH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/testing/index.ts"],"names":[],"mappings":"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","file":"chunk-LILK23YH.js","sourcesContent":["/**\n * Render-regression testing.\n *\n * Fixing a render problem once is easy; keeping it fixed is the hard part. This\n * turns render behaviour into something a pull request can fail on: record a\n * profile for a scripted interaction, commit it, and compare on every run.\n *\n * Deliberately assertion-library agnostic — it returns data and a message, and\n * your test framework decides what to do with it.\n */\nimport type { RenderEvent } from \"../core/types.js\";\n\nexport interface ComponentProfile {\n renders: number;\n remounts: number;\n avoidableRenders: number;\n}\n\nexport interface RenderProfile {\n /** Free-form label, e.g. \"search: type one character\". */\n scenario: string;\n components: Record<string, ComponentProfile>;\n}\n\nexport interface RegressionOptions {\n /**\n * Allowed growth before a component counts as regressed, as a fraction.\n * `0.2` tolerates a 20% increase. Defaults to 0 — exact.\n */\n tolerance?: number;\n /** Ignore components below this render count in the baseline. */\n ignoreBelow?: number;\n /** Component names to skip entirely. */\n ignore?: string[];\n /** Fail when a component is rendering *fewer* times too. Off by default. */\n failOnImprovement?: boolean;\n}\n\nexport interface Regression {\n component: string;\n metric: \"renders\" | \"remounts\" | \"avoidableRenders\";\n baseline: number;\n current: number;\n delta: number;\n}\n\nexport interface RegressionResult {\n ok: boolean;\n scenario: string;\n regressions: Regression[];\n improvements: Regression[];\n /** Components present now but absent from the baseline. */\n added: string[];\n message: string;\n}\n\n/** Builds a profile from recorded events. Pass `getEvents()`. */\nexport function profileFromEvents(scenario: string, events: RenderEvent[], remounts: Record<string, number> = {}): RenderProfile {\n const components: Record<string, ComponentProfile> = {};\n for (const event of events) {\n const name = event.component.name;\n const entry = (components[name] ??= { renders: 0, remounts: remounts[name] ?? 0, avoidableRenders: 0 });\n entry.renders++;\n if (event.diagnosis.potentiallyAvoidable) entry.avoidableRenders++;\n }\n return { scenario, components };\n}\n\nconst METRICS: Array<Regression[\"metric\"]> = [\"renders\", \"remounts\", \"avoidableRenders\"];\n\nexport function compareProfiles(\n baseline: RenderProfile,\n current: RenderProfile,\n options: RegressionOptions = {},\n): RegressionResult {\n const { tolerance = 0, ignoreBelow = 0, ignore = [], failOnImprovement = false } = options;\n const regressions: Regression[] = [];\n const improvements: Regression[] = [];\n const added: string[] = [];\n\n for (const [component, currentProfile] of Object.entries(current.components)) {\n if (ignore.includes(component)) continue;\n const baseProfile = baseline.components[component];\n if (!baseProfile) {\n added.push(component);\n continue;\n }\n\n for (const metric of METRICS) {\n const before = baseProfile[metric];\n const after = currentProfile[metric];\n if (before < ignoreBelow && after < ignoreBelow) continue;\n const allowed = before + Math.max(before * tolerance, 0);\n if (after > allowed) {\n regressions.push({ component, metric, baseline: before, current: after, delta: after - before });\n } else if (after < before) {\n improvements.push({ component, metric, baseline: before, current: after, delta: after - before });\n }\n }\n }\n\n const ok = regressions.length === 0 && (!failOnImprovement || improvements.length === 0);\n return { ok, scenario: current.scenario, regressions, improvements, added, message: describe(current.scenario, regressions, improvements, added) };\n}\n\nfunction describe(scenario: string, regressions: Regression[], improvements: Regression[], added: string[]): string {\n if (regressions.length === 0) {\n const parts = [`No render regressions in \"${scenario}\".`];\n if (improvements.length > 0) {\n parts.push(\n `Improved: ${improvements.map((i) => `${i.component} ${i.metric} ${i.baseline}→${i.current}`).join(\", \")}.`,\n \"If these are intended, update the baseline so they cannot silently regress again.\",\n );\n }\n if (added.length > 0) parts.push(`New components not in the baseline: ${added.join(\", \")}.`);\n return parts.join(\"\\n\");\n }\n\n const lines = [`Render regressions in \"${scenario}\":`, \"\"];\n for (const r of regressions) {\n lines.push(` ${r.component} ${r.metric}: ${r.baseline} → ${r.current} (+${r.delta})`);\n }\n lines.push(\n \"\",\n \"Each of these is a component doing more work than the baseline allows.\",\n \"Run the scenario with the overlay or `explain()` to see which prop or parent is responsible,\",\n \"or update the baseline if the change is intended.\",\n );\n return lines.join(\"\\n\");\n}\n\n/**\n * Throws when the current profile is worse than the baseline. The one-liner for\n * a test file; use `compareProfiles` when you want the data.\n */\nexport function assertNoRenderRegressions(\n baseline: RenderProfile,\n current: RenderProfile,\n options?: RegressionOptions,\n): void {\n const result = compareProfiles(baseline, current, options);\n if (!result.ok) throw new Error(result.message);\n}\n"]}
|
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
var chunkJD4IJ4MN_cjs = require('./chunk-JD4IJ4MN.cjs');
|
|
4
4
|
var chunkDZ3BZ654_cjs = require('./chunk-DZ3BZ654.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
|
|
|
@@ -138,6 +139,245 @@ function label(event) {
|
|
|
138
139
|
return "undetermined";
|
|
139
140
|
}
|
|
140
141
|
}
|
|
142
|
+
|
|
143
|
+
// src/core/interactions.ts
|
|
144
|
+
var COMMIT_SLACK_MS = 100;
|
|
145
|
+
var FALLBACK_CLOSE_MS = 50;
|
|
146
|
+
var InteractionTracker = class {
|
|
147
|
+
constructor(capacity = 50) {
|
|
148
|
+
this.capacity = capacity;
|
|
149
|
+
this.records = [];
|
|
150
|
+
this.nextId = 0;
|
|
151
|
+
}
|
|
152
|
+
/** Returns false when the browser cannot report event timing. */
|
|
153
|
+
start() {
|
|
154
|
+
if (this.observer) return true;
|
|
155
|
+
const PO = globalThis.PerformanceObserver;
|
|
156
|
+
const supported = PO?.supportedEntryTypes?.includes("event");
|
|
157
|
+
if (!PO || !supported) return false;
|
|
158
|
+
try {
|
|
159
|
+
const observer = new PO((list) => {
|
|
160
|
+
for (const entry of list.getEntries()) {
|
|
161
|
+
const timing = entry;
|
|
162
|
+
this.record({
|
|
163
|
+
name: timing.name,
|
|
164
|
+
startTime: timing.startTime,
|
|
165
|
+
duration: timing.duration,
|
|
166
|
+
target: describeTarget(timing.target)
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
observer.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
171
|
+
this.observer = observer;
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
stop() {
|
|
178
|
+
this.observer?.disconnect();
|
|
179
|
+
this.observer = void 0;
|
|
180
|
+
}
|
|
181
|
+
/** Is the automatic path available in this browser? */
|
|
182
|
+
get automatic() {
|
|
183
|
+
return this.observer !== void 0;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Time an interaction by hand.
|
|
187
|
+
*
|
|
188
|
+
* The automatic path depends on the Event Timing API, which Safari only
|
|
189
|
+
* gained in 16.4 and which does not fire for synthetic input at all — so
|
|
190
|
+
* anything driven by a test harness records nothing. This measures a specific
|
|
191
|
+
* action instead, up to the paint that follows it, and needs no browser
|
|
192
|
+
* support beyond `performance.now`.
|
|
193
|
+
*/
|
|
194
|
+
measure(label2, action) {
|
|
195
|
+
const startTime = now();
|
|
196
|
+
let handlerMs = 0;
|
|
197
|
+
let finished = false;
|
|
198
|
+
const finish = () => {
|
|
199
|
+
if (finished) return;
|
|
200
|
+
finished = true;
|
|
201
|
+
this.record({ name: label2, startTime, duration: now() - startTime, handlerMs });
|
|
202
|
+
};
|
|
203
|
+
let result;
|
|
204
|
+
try {
|
|
205
|
+
result = action();
|
|
206
|
+
handlerMs = now() - startTime;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
handlerMs = now() - startTime;
|
|
209
|
+
finish();
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
const raf = globalThis.requestAnimationFrame;
|
|
213
|
+
if (raf) raf(() => raf(finish));
|
|
214
|
+
setTimeout(finish, FALLBACK_CLOSE_MS);
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
/** Exposed for tests and for `measure`. */
|
|
218
|
+
record(timing) {
|
|
219
|
+
const record = {
|
|
220
|
+
id: `interaction_${++this.nextId}`,
|
|
221
|
+
type: timing.name,
|
|
222
|
+
target: timing.target,
|
|
223
|
+
startTime: timing.startTime,
|
|
224
|
+
durationMs: timing.duration,
|
|
225
|
+
handlerMs: timing.handlerMs,
|
|
226
|
+
renders: [],
|
|
227
|
+
renderTimeMs: 0,
|
|
228
|
+
avoidableRenderTimeMs: 0
|
|
229
|
+
};
|
|
230
|
+
this.records.push(record);
|
|
231
|
+
if (this.records.length > this.capacity) this.records.shift();
|
|
232
|
+
return record;
|
|
233
|
+
}
|
|
234
|
+
clear() {
|
|
235
|
+
this.records.length = 0;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Joins render events to interactions by commit time. A render belongs to an
|
|
239
|
+
* interaction when it committed between the event starting and shortly after
|
|
240
|
+
* it finished — React commits just after the event handler returns.
|
|
241
|
+
*/
|
|
242
|
+
attribute(events) {
|
|
243
|
+
for (const record of this.records) {
|
|
244
|
+
const from = record.startTime;
|
|
245
|
+
const to = record.startTime + record.durationMs + COMMIT_SLACK_MS;
|
|
246
|
+
record.renders = events.filter((e) => e.timings.commitTime >= from && e.timings.commitTime <= to);
|
|
247
|
+
record.renderTimeMs = record.renders.reduce((a, e) => a + e.timings.selfDuration, 0);
|
|
248
|
+
record.avoidableRenderTimeMs = record.renders.filter((e) => e.diagnosis.potentiallyAvoidable).reduce((a, e) => a + e.timings.selfDuration, 0);
|
|
249
|
+
}
|
|
250
|
+
return [...this.records].sort((a, b) => b.durationMs - a.durationMs);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function summarise(record) {
|
|
254
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
255
|
+
for (const event of record.renders) {
|
|
256
|
+
const entry = byComponent.get(event.component.name) ?? {
|
|
257
|
+
renders: 0,
|
|
258
|
+
totalMs: 0,
|
|
259
|
+
source: event.component.source,
|
|
260
|
+
cause: event.diagnosis.reason
|
|
261
|
+
};
|
|
262
|
+
entry.renders++;
|
|
263
|
+
entry.totalMs += event.timings.selfDuration;
|
|
264
|
+
byComponent.set(event.component.name, entry);
|
|
265
|
+
}
|
|
266
|
+
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);
|
|
267
|
+
const top = contributors[0];
|
|
268
|
+
const accounted = (record.handlerMs ?? record.durationMs) + record.renderTimeMs;
|
|
269
|
+
const idleWindow = record.handlerMs !== void 0 && record.durationMs > accounted * 3 && record.durationMs - accounted > 100;
|
|
270
|
+
const effectiveMs = idleWindow ? accounted : record.durationMs;
|
|
271
|
+
const share = effectiveMs > 0 ? record.renderTimeMs / effectiveMs : 0;
|
|
272
|
+
let headline;
|
|
273
|
+
let nextStep;
|
|
274
|
+
let confidence = "medium";
|
|
275
|
+
if (idleWindow) {
|
|
276
|
+
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.`;
|
|
277
|
+
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.";
|
|
278
|
+
return { interaction: record, contributors, headline, nextStep, confidence: "medium" };
|
|
279
|
+
}
|
|
280
|
+
if (record.renders.length === 0) {
|
|
281
|
+
headline = `${record.type} took ${fmt(record.durationMs)}, and no instrumented component rendered inside it.`;
|
|
282
|
+
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.";
|
|
283
|
+
confidence = "low";
|
|
284
|
+
} else if (share >= 0.4 && record.avoidableRenderTimeMs > 0) {
|
|
285
|
+
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.`;
|
|
286
|
+
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.";
|
|
287
|
+
confidence = "high";
|
|
288
|
+
} else if (share >= 0.4) {
|
|
289
|
+
headline = `${record.type} took ${fmt(record.durationMs)}; ${fmt(record.renderTimeMs)} of it was rendering, all of it explained by real input changes.`;
|
|
290
|
+
nextStep = "This is genuine work. Make the renders cheaper rather than fewer \u2014 or do less of it per interaction.";
|
|
291
|
+
confidence = "high";
|
|
292
|
+
} else {
|
|
293
|
+
headline = `${record.type} took ${fmt(record.durationMs)}, but only ${fmt(record.renderTimeMs)} was React rendering.`;
|
|
294
|
+
nextStep = "Most of the cost is outside rendering \u2014 event handlers, layout or paint. A browser profile will show more than this tool can.";
|
|
295
|
+
confidence = "medium";
|
|
296
|
+
}
|
|
297
|
+
return { interaction: record, contributors, headline, nextStep, confidence };
|
|
298
|
+
}
|
|
299
|
+
function formatInteraction(summary) {
|
|
300
|
+
const { interaction: i } = summary;
|
|
301
|
+
const lines = [
|
|
302
|
+
`${i.type}${i.target ? ` on ${i.target}` : ""} ${fmt(i.durationMs)}`,
|
|
303
|
+
"",
|
|
304
|
+
summary.headline
|
|
305
|
+
];
|
|
306
|
+
if (summary.contributors.length > 0) {
|
|
307
|
+
lines.push("", "Rendering inside this interaction");
|
|
308
|
+
for (const c of summary.contributors.slice(0, 8)) {
|
|
309
|
+
lines.push(
|
|
310
|
+
` ${c.component.padEnd(22)} ${String(c.renders).padStart(4)} render(s) ${fmt(c.totalMs).padStart(8)} ${c.cause}`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
lines.push("", `Next step`, ` ${summary.nextStep}`, "", `Confidence: ${summary.confidence}`);
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
function describeTarget(target) {
|
|
318
|
+
if (!target || typeof target !== "object") return void 0;
|
|
319
|
+
const el = target;
|
|
320
|
+
if (!el.tagName) return void 0;
|
|
321
|
+
const tag = el.tagName.toLowerCase();
|
|
322
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
323
|
+
const className = typeof el.className === "string" ? el.className.trim().split(/\s+/)[0] : void 0;
|
|
324
|
+
if (className) return `${tag}.${className}`;
|
|
325
|
+
const text = el.textContent?.trim().slice(0, 20);
|
|
326
|
+
return text ? `${tag} "${text}"` : tag;
|
|
327
|
+
}
|
|
328
|
+
var fmt = (ms) => `${ms.toFixed(1)}ms`;
|
|
329
|
+
var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
330
|
+
|
|
331
|
+
// src/core/opportunities.ts
|
|
332
|
+
var DEFAULT_MIN_SAVING_MS = 1;
|
|
333
|
+
function rankOpportunities({ events, lifecycles, minSavingMs = DEFAULT_MIN_SAVING_MS }) {
|
|
334
|
+
const names = new Set(events.map((e) => e.component.name));
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
const lifecycle = lifecycles.get(name);
|
|
338
|
+
const explanation = chunkJD4IJ4MN_cjs.explainEvents(name, events, lifecycle);
|
|
339
|
+
if (!explanation) continue;
|
|
340
|
+
const mounts = events.filter((e) => e.component.name === name && e.phase === "mount");
|
|
341
|
+
const averageMountCost = mounts.length ? mounts.reduce((a, e) => a + e.timings.selfDuration, 0) / mounts.length : 0;
|
|
342
|
+
const remountSaving = explanation.remounts * averageMountCost;
|
|
343
|
+
const estimatedSavingMs = explanation.estimatedAvoidableTime + remountSaving;
|
|
344
|
+
if (estimatedSavingMs < minSavingMs) continue;
|
|
345
|
+
out.push({
|
|
346
|
+
component: name,
|
|
347
|
+
source: explanation.source,
|
|
348
|
+
estimatedSavingMs,
|
|
349
|
+
avoidableRenders: explanation.potentiallyAvoidableRenders,
|
|
350
|
+
remounts: explanation.remounts,
|
|
351
|
+
averageSelfDuration: explanation.averageSelfDuration,
|
|
352
|
+
summary: explanation.headline,
|
|
353
|
+
nextStep: explanation.nextStep,
|
|
354
|
+
confidence: explanation.confidence
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return out.sort((a, b) => b.estimatedSavingMs - a.estimatedSavingMs);
|
|
358
|
+
}
|
|
359
|
+
function formatOpportunities(opportunities) {
|
|
360
|
+
if (opportunities.length === 0) {
|
|
361
|
+
return "React Render Detective\n\nNo measurable render waste found yet. Interact with the app and try again.";
|
|
362
|
+
}
|
|
363
|
+
const lines = [
|
|
364
|
+
"React Render Detective \u2014 where to spend your next hour",
|
|
365
|
+
"",
|
|
366
|
+
"Ranked by estimated recoverable time. These are estimates, not promises:",
|
|
367
|
+
"measure each fix.",
|
|
368
|
+
""
|
|
369
|
+
];
|
|
370
|
+
for (const [index, o] of opportunities.entries()) {
|
|
371
|
+
lines.push(
|
|
372
|
+
`${String(index + 1).padStart(2)}. ${o.component}${o.source ? ` ${o.source}` : ""}`,
|
|
373
|
+
` ~${o.estimatedSavingMs.toFixed(0)}ms recoverable ${o.avoidableRenders} avoidable render${o.avoidableRenders === 1 ? "" : "s"}${o.remounts > 0 ? `, ${o.remounts}\xD7 rebuilt` : ""} (confidence: ${o.confidence})`,
|
|
374
|
+
` ${o.summary}`,
|
|
375
|
+
` \u2192 ${o.nextStep}`,
|
|
376
|
+
""
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
return lines.join("\n");
|
|
380
|
+
}
|
|
141
381
|
var AncestryContext = React.createContext(void 0);
|
|
142
382
|
AncestryContext.displayName = "RenderDetectiveAncestry";
|
|
143
383
|
function useInstrumentedNode(name, props, source) {
|
|
@@ -305,12 +545,21 @@ function isRecord(v) {
|
|
|
305
545
|
|
|
306
546
|
// src/index.ts
|
|
307
547
|
var REPORTER = /* @__PURE__ */ Symbol.for("react-render-detective.reporter");
|
|
548
|
+
var INTERACTIONS = /* @__PURE__ */ Symbol.for("react-render-detective.interactions");
|
|
549
|
+
function tracker() {
|
|
550
|
+
const g = globalThis;
|
|
551
|
+
if (!g[INTERACTIONS]) g[INTERACTIONS] = new InteractionTracker();
|
|
552
|
+
return g[INTERACTIONS];
|
|
553
|
+
}
|
|
308
554
|
function init(options = {}) {
|
|
309
555
|
const detective = chunkDZ3BZ654_cjs.getDetective();
|
|
310
556
|
detective.init(options);
|
|
311
557
|
const g = globalThis;
|
|
312
558
|
g[REPORTER]?.();
|
|
313
559
|
g[REPORTER] = void 0;
|
|
560
|
+
if (detective.enabled) {
|
|
561
|
+
tracker().start();
|
|
562
|
+
}
|
|
314
563
|
if (detective.enabled && detective.config.mode !== "silent") {
|
|
315
564
|
g[REPORTER] = attachConsoleReporter(detective);
|
|
316
565
|
} else if (!detective.enabled) {
|
|
@@ -342,11 +591,14 @@ function subscribe(listener) {
|
|
|
342
591
|
}
|
|
343
592
|
function clear() {
|
|
344
593
|
chunkDZ3BZ654_cjs.getDetective().clear();
|
|
594
|
+
tracker().clear();
|
|
345
595
|
}
|
|
346
596
|
function reset() {
|
|
347
597
|
const g = globalThis;
|
|
348
598
|
g[REPORTER]?.();
|
|
349
599
|
g[REPORTER] = void 0;
|
|
600
|
+
g[INTERACTIONS]?.stop();
|
|
601
|
+
g[INTERACTIONS] = void 0;
|
|
350
602
|
chunkDZ3BZ654_cjs.getDetective().reset();
|
|
351
603
|
}
|
|
352
604
|
function explain(componentName2) {
|
|
@@ -356,6 +608,47 @@ function explain(componentName2) {
|
|
|
356
608
|
function explainStructured(componentName2) {
|
|
357
609
|
return chunkJD4IJ4MN_cjs.explainEvents(componentName2, getEvents(), chunkDZ3BZ654_cjs.getDetective().lifecycleOf(componentName2));
|
|
358
610
|
}
|
|
611
|
+
function getRenderProfile(scenario) {
|
|
612
|
+
const remounts = {};
|
|
613
|
+
for (const stats of chunkDZ3BZ654_cjs.getDetective().getComponentStats()) remounts[stats.name] = stats.remountCount;
|
|
614
|
+
return chunkH5RG2EPP_cjs.profileFromEvents(scenario, getEvents(), remounts);
|
|
615
|
+
}
|
|
616
|
+
function getInteractions() {
|
|
617
|
+
return tracker().attribute(getEvents());
|
|
618
|
+
}
|
|
619
|
+
function explainInteractionStructured(id) {
|
|
620
|
+
const records = getInteractions();
|
|
621
|
+
const record = id ? records.find((r) => r.id === id) : records[0];
|
|
622
|
+
return record ? summarise(record) : void 0;
|
|
623
|
+
}
|
|
624
|
+
function explainInteraction(id) {
|
|
625
|
+
const summary = explainInteractionStructured(id);
|
|
626
|
+
return summary ? formatInteraction(summary) : void 0;
|
|
627
|
+
}
|
|
628
|
+
function printInteractions(limit = 5) {
|
|
629
|
+
const records = getInteractions().slice(0, limit);
|
|
630
|
+
if (records.length === 0) {
|
|
631
|
+
console.log(
|
|
632
|
+
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."
|
|
633
|
+
);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
console.log(records.map((r) => formatInteraction(summarise(r))).join("\n\n"));
|
|
637
|
+
}
|
|
638
|
+
function measureInteraction(label2, action) {
|
|
639
|
+
return tracker().measure(label2, action);
|
|
640
|
+
}
|
|
641
|
+
function getOpportunities(limit = 10) {
|
|
642
|
+
const detective = chunkDZ3BZ654_cjs.getDetective();
|
|
643
|
+
const lifecycles = /* @__PURE__ */ new Map();
|
|
644
|
+
for (const stats of detective.getComponentStats()) {
|
|
645
|
+
lifecycles.set(stats.name, { remounts: stats.remountCount });
|
|
646
|
+
}
|
|
647
|
+
return rankOpportunities({ events: detective.getEvents(), lifecycles }).slice(0, limit);
|
|
648
|
+
}
|
|
649
|
+
function printOpportunities(limit = 10) {
|
|
650
|
+
console.log(formatOpportunities(getOpportunities(limit)));
|
|
651
|
+
}
|
|
359
652
|
function printStats() {
|
|
360
653
|
const s = getStats();
|
|
361
654
|
const lines = [
|
|
@@ -401,6 +694,14 @@ var ReactRenderDetective = {
|
|
|
401
694
|
reset,
|
|
402
695
|
explain,
|
|
403
696
|
explainStructured,
|
|
697
|
+
getOpportunities,
|
|
698
|
+
printOpportunities,
|
|
699
|
+
getInteractions,
|
|
700
|
+
explainInteraction,
|
|
701
|
+
explainInteractionStructured,
|
|
702
|
+
printInteractions,
|
|
703
|
+
measureInteraction,
|
|
704
|
+
getRenderProfile,
|
|
404
705
|
printStats
|
|
405
706
|
};
|
|
406
707
|
|
|
@@ -412,21 +713,34 @@ Object.defineProperty(exports, "formatExplanation", {
|
|
|
412
713
|
enumerable: true,
|
|
413
714
|
get: function () { return chunkJD4IJ4MN_cjs.formatExplanation; }
|
|
414
715
|
});
|
|
716
|
+
exports.InteractionTracker = InteractionTracker;
|
|
415
717
|
exports.ReactRenderDetective = ReactRenderDetective;
|
|
416
718
|
exports.RenderDetective = RenderDetective;
|
|
417
719
|
exports.clear = clear;
|
|
418
720
|
exports.configure = configure;
|
|
419
721
|
exports.explain = explain;
|
|
722
|
+
exports.explainInteraction = explainInteraction;
|
|
723
|
+
exports.explainInteractionStructured = explainInteractionStructured;
|
|
420
724
|
exports.explainStructured = explainStructured;
|
|
725
|
+
exports.formatInteraction = formatInteraction;
|
|
726
|
+
exports.formatOpportunities = formatOpportunities;
|
|
421
727
|
exports.getComponentStats = getComponentStats;
|
|
422
728
|
exports.getConfig = getConfig;
|
|
423
729
|
exports.getEvents = getEvents;
|
|
730
|
+
exports.getInteractions = getInteractions;
|
|
731
|
+
exports.getOpportunities = getOpportunities;
|
|
732
|
+
exports.getRenderProfile = getRenderProfile;
|
|
424
733
|
exports.getStats = getStats;
|
|
425
734
|
exports.init = init;
|
|
426
735
|
exports.isEnabled = isEnabled;
|
|
736
|
+
exports.measureInteraction = measureInteraction;
|
|
737
|
+
exports.printInteractions = printInteractions;
|
|
738
|
+
exports.printOpportunities = printOpportunities;
|
|
427
739
|
exports.printStats = printStats;
|
|
740
|
+
exports.rankOpportunities = rankOpportunities;
|
|
428
741
|
exports.reset = reset;
|
|
429
742
|
exports.subscribe = subscribe;
|
|
743
|
+
exports.summariseInteraction = summarise;
|
|
430
744
|
exports.useRenderDiagnostics = useRenderDiagnostics;
|
|
431
745
|
exports.useTrackedContextValue = useTrackedContextValue;
|
|
432
746
|
exports.useTrackedEffect = useTrackedEffect;
|