rerender-lens 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +192 -353
- package/dist/{budget-COBu7jBU.d.cts → budget-BnJlYwrV.d.ts} +20 -10
- package/dist/{budget-LkNjGtRc.d.ts → budget-CRswn-nC.d.cts} +20 -10
- package/dist/cli.cjs +248 -31
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +248 -31
- package/dist/cli.js.map +1 -1
- package/dist/{devtools-BHGUUo-p.d.ts → devtools-BYWat7nQ.d.ts} +17 -3
- package/dist/{devtools-DmhWiWcN.d.cts → devtools-DJi_6AfJ.d.cts} +17 -3
- package/dist/index.cjs +635 -109
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -8
- package/dist/index.d.ts +131 -8
- package/dist/index.js +620 -110
- package/dist/index.js.map +1 -1
- package/dist/jest-setup.cjs +1673 -0
- package/dist/jest-setup.cjs.map +1 -0
- package/dist/jest-setup.d.cts +8 -0
- package/dist/jest-setup.d.ts +8 -0
- package/dist/jest-setup.js +1671 -0
- package/dist/jest-setup.js.map +1 -0
- package/dist/jest.cjs +1760 -0
- package/dist/jest.cjs.map +1 -0
- package/dist/jest.d.cts +23 -0
- package/dist/jest.d.ts +23 -0
- package/dist/jest.js +1751 -0
- package/dist/jest.js.map +1 -0
- package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-DwKlvv4H.d.cts} +8 -4
- package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-FNzKHs0a.d.ts} +8 -4
- package/dist/playwright.cjs +235 -30
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.cts +8 -6
- package/dist/playwright.d.ts +8 -6
- package/dist/playwright.js +235 -31
- package/dist/playwright.js.map +1 -1
- package/dist/relay.cjs +10 -2
- package/dist/relay.cjs.map +1 -1
- package/dist/relay.js +10 -2
- package/dist/relay.js.map +1 -1
- package/dist/rerender-lens.iife.js +1100 -337
- package/dist/runner-report-B8Sc3FZO.d.cts +46 -0
- package/dist/runner-report-CW8X5RwK.d.ts +46 -0
- package/dist/setup.cjs +308 -95
- package/dist/setup.cjs.map +1 -1
- package/dist/setup.js +308 -95
- package/dist/setup.js.map +1 -1
- package/dist/{types-BzUEVkxJ.d.cts → types-DW5-N2XH.d.cts} +55 -2
- package/dist/{types-BzUEVkxJ.d.ts → types-DW5-N2XH.d.ts} +55 -2
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vitest-setup.cjs +452 -87
- package/dist/vitest-setup.cjs.map +1 -1
- package/dist/vitest-setup.d.cts +4 -4
- package/dist/vitest-setup.d.ts +4 -4
- package/dist/vitest-setup.js +452 -87
- package/dist/vitest-setup.js.map +1 -1
- package/dist/vitest.cjs +515 -123
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +21 -39
- package/dist/vitest.d.ts +21 -39
- package/dist/vitest.js +516 -124
- package/dist/vitest.js.map +1 -1
- package/package.json +41 -5
- package/panel/panel.css +1 -34
- package/panel/panel.js +865 -713
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { R as RenderReport } from './types-
|
|
1
|
+
import { a as ReportLike, R as RenderReport } from './types-DW5-N2XH.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Concrete fixes derived from reports, ranked by how many avoidable re-renders each one removes.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* One implementation for the library (tests, CI budgets, the CLI, typed on `RenderReport`) and the
|
|
6
|
+
* DevTools panel (serialized reports): everything here reads only `ReportLike`. Pure.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
type FixKind = 'memo' | 'useCallback' | 'useMemo' | 'useMemoElement' | 'children' | 'contextValue' | 'splitContext' | 'storeSnapshot' | 'bailout';
|
|
@@ -16,19 +16,29 @@ interface Fix {
|
|
|
16
16
|
prop: string | null;
|
|
17
17
|
label: string;
|
|
18
18
|
detail: string;
|
|
19
|
+
/** Code sketch of the fix, `// <owner>` on the first line. */
|
|
20
|
+
snippet: string;
|
|
19
21
|
}
|
|
20
|
-
interface RankedFix extends Fix {
|
|
22
|
+
interface RankedFix<R extends ReportLike = RenderReport> extends Fix {
|
|
21
23
|
key: string;
|
|
22
24
|
/** Avoidable re-renders this fix removes. */
|
|
23
25
|
count: number;
|
|
24
26
|
components: Record<string, number>;
|
|
27
|
+
/** The first (at most 50) reports the fix applies to. */
|
|
28
|
+
reports: R[];
|
|
25
29
|
}
|
|
26
|
-
|
|
30
|
+
/** `JSON.stringify` cut to `max` characters, for snippets and markdown; never throws. */
|
|
31
|
+
declare function shortValue(v: unknown, max?: number): string;
|
|
32
|
+
/** The concrete fixes one report suggests, each attributed to the component (file) that must change. */
|
|
33
|
+
declare function fixesFor(r: ReportLike): Fix[];
|
|
27
34
|
declare const fixKey: (f: Fix) => string;
|
|
28
|
-
/** Every fix across `reports`, most re-renders removed first. */
|
|
29
|
-
declare function rankFixes(reports:
|
|
30
|
-
/**
|
|
31
|
-
|
|
35
|
+
/** Every fix across `reports`, most avoidable re-renders removed first. */
|
|
36
|
+
declare function rankFixes<R extends ReportLike>(reports: readonly R[]): RankedFix<R>[];
|
|
37
|
+
/**
|
|
38
|
+
* Multi-line text for a test failure or a CI log: the ranked fixes, then (when the reports carry
|
|
39
|
+
* `commitId`) a "Root causes" section naming the components whose own changes started the cascades.
|
|
40
|
+
*/
|
|
41
|
+
declare function formatFixes(reports: readonly ReportLike[], limit?: number): string;
|
|
32
42
|
|
|
33
43
|
/**
|
|
34
44
|
* Re-render budgets for CI: a JSON baseline of allowed avoidable re-renders per component, checked
|
|
@@ -61,4 +71,4 @@ declare function toBudget(reports: RenderReport[]): Budget;
|
|
|
61
71
|
/** Throws with the violations and the ranked fixes; use it from a test. */
|
|
62
72
|
declare function assertWithinBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
|
|
63
73
|
|
|
64
|
-
export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, toBudget as t };
|
|
74
|
+
export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, shortValue as s, toBudget as t };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { R as RenderReport } from './types-
|
|
1
|
+
import { a as ReportLike, R as RenderReport } from './types-DW5-N2XH.cjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Concrete fixes derived from reports, ranked by how many avoidable re-renders each one removes.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* One implementation for the library (tests, CI budgets, the CLI, typed on `RenderReport`) and the
|
|
6
|
+
* DevTools panel (serialized reports): everything here reads only `ReportLike`. Pure.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
type FixKind = 'memo' | 'useCallback' | 'useMemo' | 'useMemoElement' | 'children' | 'contextValue' | 'splitContext' | 'storeSnapshot' | 'bailout';
|
|
@@ -16,19 +16,29 @@ interface Fix {
|
|
|
16
16
|
prop: string | null;
|
|
17
17
|
label: string;
|
|
18
18
|
detail: string;
|
|
19
|
+
/** Code sketch of the fix, `// <owner>` on the first line. */
|
|
20
|
+
snippet: string;
|
|
19
21
|
}
|
|
20
|
-
interface RankedFix extends Fix {
|
|
22
|
+
interface RankedFix<R extends ReportLike = RenderReport> extends Fix {
|
|
21
23
|
key: string;
|
|
22
24
|
/** Avoidable re-renders this fix removes. */
|
|
23
25
|
count: number;
|
|
24
26
|
components: Record<string, number>;
|
|
27
|
+
/** The first (at most 50) reports the fix applies to. */
|
|
28
|
+
reports: R[];
|
|
25
29
|
}
|
|
26
|
-
|
|
30
|
+
/** `JSON.stringify` cut to `max` characters, for snippets and markdown; never throws. */
|
|
31
|
+
declare function shortValue(v: unknown, max?: number): string;
|
|
32
|
+
/** The concrete fixes one report suggests, each attributed to the component (file) that must change. */
|
|
33
|
+
declare function fixesFor(r: ReportLike): Fix[];
|
|
27
34
|
declare const fixKey: (f: Fix) => string;
|
|
28
|
-
/** Every fix across `reports`, most re-renders removed first. */
|
|
29
|
-
declare function rankFixes(reports:
|
|
30
|
-
/**
|
|
31
|
-
|
|
35
|
+
/** Every fix across `reports`, most avoidable re-renders removed first. */
|
|
36
|
+
declare function rankFixes<R extends ReportLike>(reports: readonly R[]): RankedFix<R>[];
|
|
37
|
+
/**
|
|
38
|
+
* Multi-line text for a test failure or a CI log: the ranked fixes, then (when the reports carry
|
|
39
|
+
* `commitId`) a "Root causes" section naming the components whose own changes started the cascades.
|
|
40
|
+
*/
|
|
41
|
+
declare function formatFixes(reports: readonly ReportLike[], limit?: number): string;
|
|
32
42
|
|
|
33
43
|
/**
|
|
34
44
|
* Re-render budgets for CI: a JSON baseline of allowed avoidable re-renders per component, checked
|
|
@@ -61,4 +71,4 @@ declare function toBudget(reports: RenderReport[]): Budget;
|
|
|
61
71
|
/** Throws with the violations and the ranked fixes; use it from a test. */
|
|
62
72
|
declare function assertWithinBudget(reports: RenderReport[], budget: Budget | number): BudgetResult;
|
|
63
73
|
|
|
64
|
-
export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, toBudget as t };
|
|
74
|
+
export { type Budget as B, type Fix as F, type RankedFix as R, type BudgetResult as a, type BudgetViolation as b, checkBudget as c, type FixKind as d, assertWithinBudget as e, formatFixes as f, avoidableCounts as g, fixKey as h, fixesFor as i, rankFixes as r, shortValue as s, toBudget as t };
|
package/dist/cli.cjs
CHANGED
|
@@ -10,50 +10,206 @@ var url = require('url');
|
|
|
10
10
|
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
11
11
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
12
12
|
|
|
13
|
-
// src/
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
// src/diff.ts
|
|
14
|
+
var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
|
|
15
|
+
|
|
16
|
+
// src/causes.ts
|
|
17
|
+
var isAncestorReport = (anc, r) => anc.path.length < r.path.length && r.path[anc.path.length] === anc.component && anc.path.every((p, i) => r.path[i] === p);
|
|
18
|
+
var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
|
|
19
|
+
function indexByComponent(reports) {
|
|
20
|
+
const index = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const r of reports) {
|
|
22
|
+
const list = index.get(r.component);
|
|
23
|
+
if (list) list.push(r);
|
|
24
|
+
else index.set(r.component, [r]);
|
|
25
|
+
}
|
|
26
|
+
return index;
|
|
27
|
+
}
|
|
28
|
+
function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
|
|
29
|
+
let cur = r;
|
|
30
|
+
const seen = /* @__PURE__ */ new Set([r]);
|
|
31
|
+
while (cur.trigger === "parent" && cur.parent) {
|
|
32
|
+
const parent = cur.parent;
|
|
33
|
+
const candidates = index.get(parent.name);
|
|
34
|
+
const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
|
|
35
|
+
if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
|
|
36
|
+
seen.add(p);
|
|
37
|
+
cur = p;
|
|
38
|
+
}
|
|
39
|
+
return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
|
|
40
|
+
}
|
|
41
|
+
function rootCausesOf(reports) {
|
|
42
|
+
const roots = /* @__PURE__ */ new Map();
|
|
43
|
+
const rootByReport = /* @__PURE__ */ new Map();
|
|
44
|
+
const index = indexByComponent(reports);
|
|
45
|
+
let avoidable = 0;
|
|
46
|
+
let wasted = 0;
|
|
47
|
+
for (const r of reports) {
|
|
48
|
+
if (!r.avoidable) continue;
|
|
49
|
+
avoidable++;
|
|
50
|
+
if (typeof r.selfDuration === "number") wasted += r.selfDuration;
|
|
51
|
+
const root = rootCauseOf(r, reports, index);
|
|
52
|
+
const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
|
|
53
|
+
const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
|
|
54
|
+
rootByReport.set(r, name);
|
|
55
|
+
let agg = roots.get(name);
|
|
56
|
+
if (!agg) {
|
|
57
|
+
agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
|
|
58
|
+
roots.set(name, agg);
|
|
59
|
+
}
|
|
60
|
+
agg.count++;
|
|
61
|
+
bump(agg.components, r.component);
|
|
62
|
+
}
|
|
63
|
+
return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
|
|
64
|
+
}
|
|
65
|
+
function groupByCommit(reports) {
|
|
66
|
+
const out = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const r of reports) {
|
|
68
|
+
if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
|
|
69
|
+
const list = out.get(r.commitId);
|
|
70
|
+
if (list) list.push(r);
|
|
71
|
+
else out.set(r.commitId, [r]);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function rankRootCauses(reports) {
|
|
76
|
+
const byName = /* @__PURE__ */ new Map();
|
|
77
|
+
for (const commit of groupByCommit(reports).values()) {
|
|
78
|
+
for (const root of rootCausesOf(commit).roots) {
|
|
79
|
+
let agg = byName.get(root.name);
|
|
80
|
+
if (!agg) {
|
|
81
|
+
agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
|
|
82
|
+
byName.set(root.name, agg);
|
|
83
|
+
}
|
|
84
|
+
agg.commits++;
|
|
85
|
+
agg.count += root.count;
|
|
86
|
+
for (const [c, n] of root.components) bump(agg.components, c, n);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
|
|
90
|
+
}
|
|
91
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
92
|
+
function formatRootCauses(reports, limit = 5) {
|
|
93
|
+
const ranked = rankRootCauses(reports);
|
|
94
|
+
const lines = ranked.slice(0, limit).map((root, i) => `${String(i + 1).padStart(2)}. <${root.name}> (${root.trigger}) started ${plural(root.commits, "commit")}, ${plural(root.count, "avoidable re-render")} (${[...root.components].map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
|
|
95
|
+
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
96
|
+
return lines.join("\n");
|
|
23
97
|
}
|
|
24
98
|
|
|
25
99
|
// src/fixes.ts
|
|
26
|
-
var
|
|
100
|
+
var MAX_FIX_REPORTS = 50;
|
|
27
101
|
var rootOf = (path) => path.split(/[.[]/)[0] || path;
|
|
102
|
+
var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
|
|
103
|
+
function shortValue(v, max = 60) {
|
|
104
|
+
let s;
|
|
105
|
+
try {
|
|
106
|
+
s = JSON.stringify(v);
|
|
107
|
+
} catch {
|
|
108
|
+
s = String(v);
|
|
109
|
+
}
|
|
110
|
+
if (s === void 0) s = String(v);
|
|
111
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
112
|
+
}
|
|
28
113
|
function fixesFor(r) {
|
|
29
114
|
const out = [];
|
|
30
|
-
const
|
|
31
|
-
const
|
|
115
|
+
const ownerName = r.owner || r.parent && r.parent.name || null;
|
|
116
|
+
const propChanges = r.propChanges || [];
|
|
117
|
+
const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
|
|
32
118
|
if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
|
|
119
|
+
const identical = changes.length === 0;
|
|
33
120
|
out.push({
|
|
34
121
|
kind: "memo",
|
|
35
122
|
owner: r.component,
|
|
36
123
|
target: r.component,
|
|
37
124
|
prop: null,
|
|
38
125
|
label: `Wrap <${r.component}> in React.memo`,
|
|
39
|
-
detail:
|
|
126
|
+
detail: identical ? `<${r.component}> re-rendered with identical props because <${r.parent && r.parent.name || "its parent"}> re-rendered.` : `<${r.component}> is not memoized: fixing its props alone will not stop the re-render.`,
|
|
127
|
+
snippet: `// ${r.component}
|
|
128
|
+
import { memo } from 'react';
|
|
129
|
+
|
|
130
|
+
export const ${r.component} = memo(function ${r.component}(props) {
|
|
131
|
+
// ...
|
|
132
|
+
});
|
|
133
|
+
// class components: extend PureComponent instead`
|
|
40
134
|
});
|
|
41
135
|
}
|
|
42
|
-
for (const c of
|
|
43
|
-
if (!
|
|
136
|
+
for (const c of propChanges) {
|
|
137
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
138
|
+
const owner = ownerName || "?";
|
|
44
139
|
const root = rootOf(c.path);
|
|
140
|
+
const id = identifier(root);
|
|
45
141
|
if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
|
|
46
|
-
out.push({
|
|
142
|
+
out.push({
|
|
143
|
+
kind: "children",
|
|
144
|
+
owner,
|
|
145
|
+
target: r.component,
|
|
146
|
+
prop: "children",
|
|
147
|
+
label: `memoize children of <${r.component}> in <${owner}>`,
|
|
148
|
+
detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
|
|
149
|
+
snippet: `// ${owner}
|
|
150
|
+
import { useMemo } from 'react';
|
|
151
|
+
|
|
152
|
+
const children = useMemo(() => (
|
|
153
|
+
<>{/* the same elements */}</>
|
|
154
|
+
), [/* deps */]);
|
|
155
|
+
|
|
156
|
+
<${r.component}>{children}</${r.component}>
|
|
157
|
+
|
|
158
|
+
// static children: hoist them to module scope
|
|
159
|
+
const STATIC = <em>hi</em>;`
|
|
160
|
+
});
|
|
47
161
|
} else if (c.kind === "function") {
|
|
48
|
-
out.push({
|
|
162
|
+
out.push({
|
|
163
|
+
kind: "useCallback",
|
|
164
|
+
owner,
|
|
165
|
+
target: r.component,
|
|
166
|
+
prop: root,
|
|
167
|
+
label: `useCallback(${root}) in <${owner}>`,
|
|
168
|
+
detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
|
|
169
|
+
snippet: `// ${owner}
|
|
170
|
+
import { useCallback } from 'react';
|
|
171
|
+
|
|
172
|
+
const ${id} = useCallback((/* args */) => {
|
|
173
|
+
// ...
|
|
174
|
+
}, [/* deps */]);
|
|
175
|
+
|
|
176
|
+
<${r.component} ${root}={${id}} />`
|
|
177
|
+
});
|
|
49
178
|
} else if (c.kind === "element") {
|
|
50
|
-
out.push({
|
|
179
|
+
out.push({
|
|
180
|
+
kind: "useMemoElement",
|
|
181
|
+
owner,
|
|
182
|
+
target: r.component,
|
|
183
|
+
prop: root,
|
|
184
|
+
label: `memoize element prop ${root} in <${owner}>`,
|
|
185
|
+
detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
|
|
186
|
+
snippet: `// ${owner}
|
|
187
|
+
import { useMemo } from 'react';
|
|
188
|
+
|
|
189
|
+
const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
|
|
190
|
+
// or pass it as children from a component that does not re-render`
|
|
191
|
+
});
|
|
51
192
|
} else {
|
|
52
|
-
|
|
193
|
+
const isArray = Array.isArray(c.next);
|
|
194
|
+
out.push({
|
|
195
|
+
kind: "useMemo",
|
|
196
|
+
owner,
|
|
197
|
+
target: r.component,
|
|
198
|
+
prop: root,
|
|
199
|
+
label: `useMemo(${root}) in <${owner}>`,
|
|
200
|
+
detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
|
|
201
|
+
snippet: `// ${owner}
|
|
202
|
+
import { useMemo } from 'react';
|
|
203
|
+
|
|
204
|
+
const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
|
|
205
|
+
|
|
206
|
+
// or, when it never changes, hoist it to module scope:
|
|
207
|
+
const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
|
|
208
|
+
});
|
|
53
209
|
}
|
|
54
210
|
}
|
|
55
|
-
for (const c of [...r.stateChanges, ...r.hookChanges]) {
|
|
56
|
-
const isContext = c.hook === "useContext";
|
|
211
|
+
for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
|
|
212
|
+
const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
|
|
57
213
|
const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
|
|
58
214
|
const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
|
|
59
215
|
if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
|
|
@@ -63,20 +219,63 @@ function fixesFor(r) {
|
|
|
63
219
|
target: r.component,
|
|
64
220
|
prop: ctxName,
|
|
65
221
|
label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
|
|
66
|
-
detail: `${c.changedKeys.
|
|
222
|
+
detail: `${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed in ${ctxName}, yet every consumer (like <${r.component}>) re-rendered. Consumers that read the other keys re-render for nothing.`,
|
|
223
|
+
snippet: `// ${providerOwner || "Provider"}
|
|
224
|
+
// one context per independently-changing slice
|
|
225
|
+
const ${identifier(ctxName)}Static = createContext(...);
|
|
226
|
+
const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
|
|
227
|
+
|
|
228
|
+
// or keep one context and let consumers select a slice:
|
|
229
|
+
const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
|
|
67
230
|
});
|
|
68
231
|
continue;
|
|
69
232
|
}
|
|
70
|
-
if (!
|
|
233
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
71
234
|
if (isContext) {
|
|
72
|
-
out.push({
|
|
235
|
+
out.push({
|
|
236
|
+
kind: "contextValue",
|
|
237
|
+
owner: providerOwner || `${ctxName}.Provider`,
|
|
238
|
+
target: r.component,
|
|
239
|
+
prop: ctxName,
|
|
240
|
+
label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
|
|
241
|
+
detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
|
|
242
|
+
snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
|
|
243
|
+
const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
|
|
244
|
+
<${ctxName}.Provider value={value}>`
|
|
245
|
+
});
|
|
73
246
|
} else if (c.hook === "useSyncExternalStore") {
|
|
74
247
|
const chain = c.custom || [];
|
|
75
248
|
const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
|
|
76
249
|
const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
|
|
77
|
-
|
|
250
|
+
const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
|
|
251
|
+
out.push({
|
|
252
|
+
kind: "storeSnapshot",
|
|
253
|
+
owner: r.component,
|
|
254
|
+
target: r.component,
|
|
255
|
+
prop: c.path,
|
|
256
|
+
label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
|
|
257
|
+
detail: redux ? `the selector${via} returns a new object on every call, so the component re-renders on every store change.` : zustand ? `the store selector${via} returns a new object on every call, so the component re-renders on every store change.` : `${c.path}${via} returned a new reference with the same contents; getSnapshot must return a cached value.`,
|
|
258
|
+
snippet: redux ? `// ${r.component}
|
|
259
|
+
import { shallowEqual } from 'react-redux';
|
|
260
|
+
const slice = useSelector(selectSlice, shallowEqual);
|
|
261
|
+
// or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
|
|
262
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
263
|
+
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
264
|
+
// or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
|
|
265
|
+
// getSnapshot must return the same reference while the data is unchanged
|
|
266
|
+
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
|
|
267
|
+
});
|
|
78
268
|
} else {
|
|
79
|
-
out.push({
|
|
269
|
+
out.push({
|
|
270
|
+
kind: "bailout",
|
|
271
|
+
owner: r.component,
|
|
272
|
+
target: r.component,
|
|
273
|
+
prop: c.path,
|
|
274
|
+
label: `bail out before setting ${c.path} in <${r.component}>`,
|
|
275
|
+
detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
|
|
276
|
+
snippet: `// ${r.component}
|
|
277
|
+
setState((prev) => (deepEqual(prev, next) ? prev : next));`
|
|
278
|
+
});
|
|
80
279
|
}
|
|
81
280
|
}
|
|
82
281
|
return out;
|
|
@@ -90,11 +289,12 @@ function rankFixes(reports) {
|
|
|
90
289
|
const key = fixKey(f);
|
|
91
290
|
let agg = byKey.get(key);
|
|
92
291
|
if (!agg) {
|
|
93
|
-
agg = { ...f, key, count: 0, components: {} };
|
|
292
|
+
agg = { ...f, key, count: 0, components: {}, reports: [] };
|
|
94
293
|
byKey.set(key, agg);
|
|
95
294
|
}
|
|
96
295
|
agg.count++;
|
|
97
296
|
agg.components[r.component] = (agg.components[r.component] || 0) + 1;
|
|
297
|
+
if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
|
|
98
298
|
}
|
|
99
299
|
}
|
|
100
300
|
return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
@@ -104,6 +304,8 @@ function formatFixes(reports, limit = 10) {
|
|
|
104
304
|
if (!ranked.length) return "No avoidable re-renders.";
|
|
105
305
|
const lines = ranked.slice(0, limit).map((f, i) => `${String(i + 1).padStart(2)}. ${f.label} (removes ${f.count}: ${Object.entries(f.components).map(([c, n]) => `<${c}>${n > 1 ? " x" + n : ""}`).join(", ")})`);
|
|
106
306
|
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
307
|
+
const roots = formatRootCauses(reports, 5);
|
|
308
|
+
if (roots) lines.push("", "Root causes:", roots);
|
|
107
309
|
return lines.join("\n");
|
|
108
310
|
}
|
|
109
311
|
|
|
@@ -293,12 +495,20 @@ function createRelayServer(options = {}) {
|
|
|
293
495
|
clearInterval(ping);
|
|
294
496
|
role.delete(res);
|
|
295
497
|
});
|
|
498
|
+
const count = () => ({ __rerenderLens: true, version: 2, type: "relay", payload: { apps: apps.size } });
|
|
296
499
|
if (role === apps) {
|
|
297
|
-
send(panels,
|
|
298
|
-
req.on("close", () => send(panels,
|
|
500
|
+
send(panels, count());
|
|
501
|
+
req.on("close", () => send(panels, count()));
|
|
502
|
+
} else {
|
|
503
|
+
send(/* @__PURE__ */ new Set([res]), count());
|
|
299
504
|
}
|
|
300
505
|
return;
|
|
301
506
|
}
|
|
507
|
+
if (u.pathname === "/favicon.ico") {
|
|
508
|
+
res.statusCode = 204;
|
|
509
|
+
res.end();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
302
512
|
if (u.pathname === "/message" && req.method === "POST") {
|
|
303
513
|
try {
|
|
304
514
|
const parsed = JSON.parse(await readBody(req) || "null");
|
|
@@ -399,6 +609,13 @@ function main(argv) {
|
|
|
399
609
|
console.log(formatFixes(reports, Number(flag("--limit") || 20)));
|
|
400
610
|
return 0;
|
|
401
611
|
}
|
|
612
|
+
case "causes": {
|
|
613
|
+
const file = positional[0];
|
|
614
|
+
if (!file) throw new Error("usage: rerender-lens causes <export.json> [--limit N]");
|
|
615
|
+
const { reports } = parseExport(read(file));
|
|
616
|
+
console.log(formatRootCauses(reports, Number(flag("--limit") || 10)) || "No root causes: no avoidable re-renders, or the reports carry no commit ids.");
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
402
619
|
case "summary": {
|
|
403
620
|
const file = positional[0];
|
|
404
621
|
if (!file) throw new Error("usage: rerender-lens summary <export.json> [--out summary.json]");
|
|
@@ -442,7 +659,7 @@ ${cmp.regressions.length} component${cmp.regressions.length === 1 ? "" : "s"} re
|
|
|
442
659
|
return 1;
|
|
443
660
|
}
|
|
444
661
|
default:
|
|
445
|
-
console.error("usage: rerender-lens <panel|fixes|summary|compare|budget> ...");
|
|
662
|
+
console.error("usage: rerender-lens <panel|fixes|causes|summary|compare|budget> ...");
|
|
446
663
|
return cmd ? 1 : 0;
|
|
447
664
|
}
|
|
448
665
|
} catch (e) {
|