rerender-lens 0.4.0 → 0.7.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 +127 -0
- package/README.md +208 -359
- package/dist/{budget-LkNjGtRc.d.ts → budget-CA1MS7lB.d.cts} +20 -10
- package/dist/{budget-COBu7jBU.d.cts → budget-ok1VDJ4t.d.ts} +20 -10
- package/dist/cli.cjs +322 -53
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +322 -53
- package/dist/cli.js.map +1 -1
- package/dist/{devtools-BkCct3cJ.d.cts → devtools-Cl5n_52v.d.ts} +17 -2
- package/dist/{devtools-CZebzpF6.d.ts → devtools-UwQx2VYS.d.cts} +17 -2
- package/dist/index.cjs +537 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -8
- package/dist/index.d.ts +137 -8
- package/dist/index.js +521 -50
- package/dist/index.js.map +1 -1
- package/dist/jest-setup.cjs +1690 -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 +1688 -0
- package/dist/jest-setup.js.map +1 -0
- package/dist/jest.cjs +1777 -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 +1768 -0
- package/dist/jest.js.map +1 -0
- package/dist/{notifiers-BjjGSHpp.d.ts → notifiers-B5poAEvE.d.cts} +8 -4
- package/dist/{notifiers-BGQtWKfX.d.cts → notifiers-CqQW0Eum.d.ts} +8 -4
- package/dist/playwright.cjs +230 -28
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.cts +4 -4
- package/dist/playwright.d.ts +4 -4
- package/dist/playwright.js +230 -28
- package/dist/playwright.js.map +1 -1
- package/dist/relay.cjs +71 -19
- package/dist/relay.cjs.map +1 -1
- package/dist/relay.d.cts +25 -3
- package/dist/relay.d.ts +25 -3
- package/dist/relay.js +71 -19
- package/dist/relay.js.map +1 -1
- package/dist/rerender-lens.iife.js +457 -62
- package/dist/runner-report-Bvqoe89M.d.ts +46 -0
- package/dist/runner-report-CvpDg2zt.d.cts +46 -0
- package/dist/setup.cjs +209 -35
- package/dist/setup.cjs.map +1 -1
- package/dist/setup.js +209 -35
- package/dist/setup.js.map +1 -1
- package/dist/{types-BzUEVkxJ.d.cts → types-DwNN5cWe.d.cts} +82 -2
- package/dist/{types-BzUEVkxJ.d.ts → types-DwNN5cWe.d.ts} +82 -2
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vitest-setup.cjs +357 -44
- 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 +357 -44
- package/dist/vitest-setup.js.map +1 -1
- package/dist/vitest.cjs +420 -80
- 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 +421 -81
- package/dist/vitest.js.map +1 -1
- package/package.json +41 -4
- package/panel/panel.css +49 -0
- package/panel/panel.js +871 -353
package/dist/vitest.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
2
2
|
import { tmpdir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
|
-
// src/
|
|
5
|
+
// src/runner-report.ts
|
|
6
6
|
|
|
7
7
|
// src/diff.ts
|
|
8
8
|
var REACT_ELEMENT = /* @__PURE__ */ Symbol.for("react.element");
|
|
@@ -126,6 +126,7 @@ function deepEqualObjects(a, b, seen) {
|
|
|
126
126
|
}
|
|
127
127
|
return false;
|
|
128
128
|
}
|
|
129
|
+
var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
|
|
129
130
|
function classify(prev, next) {
|
|
130
131
|
if (typeof prev === "function" && typeof next === "function") {
|
|
131
132
|
return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
|
|
@@ -162,30 +163,30 @@ function diffRecords(prev, next, basePath = "") {
|
|
|
162
163
|
if (Object.is(a, b)) continue;
|
|
163
164
|
const kind = classify(a, b);
|
|
164
165
|
if (kind === "different") {
|
|
165
|
-
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path), kind, prev: a, next: b });
|
|
166
|
+
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path) ?? path, kind, prev: a, next: b });
|
|
166
167
|
} else {
|
|
167
168
|
changes.push({ path, kind, prev: a, next: b });
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
return changes;
|
|
171
172
|
}
|
|
172
|
-
function firstDifferentPath(a, b, path, depth = 0) {
|
|
173
|
-
if (depth > 8) return path;
|
|
173
|
+
function firstDifferentPath(a, b, path = "", depth = 0) {
|
|
174
|
+
if (depth > 8) return path || "(value)";
|
|
174
175
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
175
|
-
if (a.length !== b.length) return `${path}.length
|
|
176
|
+
if (a.length !== b.length) return path ? `${path}.length` : "length";
|
|
176
177
|
for (let i = 0; i < a.length; i++) {
|
|
177
178
|
if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
|
|
178
179
|
}
|
|
179
|
-
return
|
|
180
|
+
return null;
|
|
180
181
|
}
|
|
181
182
|
if (isPlainObject(a) && isPlainObject(b)) {
|
|
182
183
|
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
183
184
|
for (const k of keys) {
|
|
184
185
|
if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
|
|
185
186
|
}
|
|
186
|
-
return
|
|
187
|
+
return null;
|
|
187
188
|
}
|
|
188
|
-
return path;
|
|
189
|
+
return deepEqual(a, b) ? null : path || "(value)";
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
// src/report.ts
|
|
@@ -203,10 +204,12 @@ function storeAdvice(custom) {
|
|
|
203
204
|
}
|
|
204
205
|
var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
205
206
|
var isChildren = (change) => change.path === "children";
|
|
207
|
+
var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
|
|
206
208
|
function fixFor(change) {
|
|
207
209
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
208
210
|
return `lift the children out of the parent's render: memoize them with useMemo, hoist static elements to module scope, or render them from a component that does not re-render`;
|
|
209
211
|
}
|
|
212
|
+
if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
|
|
210
213
|
switch (change.kind) {
|
|
211
214
|
case "function":
|
|
212
215
|
return `wrap it in useCallback (or hoist it out of the parent's render)`;
|
|
@@ -222,6 +225,7 @@ function describe(change) {
|
|
|
222
225
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
223
226
|
return `children are new React elements with the same types and props on every render of the parent`;
|
|
224
227
|
}
|
|
228
|
+
if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
|
|
225
229
|
switch (change.kind) {
|
|
226
230
|
case "deep-equal":
|
|
227
231
|
return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
|
|
@@ -249,11 +253,21 @@ function buildReport(input) {
|
|
|
249
253
|
if (causes.length === 0) trigger = "parent";
|
|
250
254
|
else if (causes.length === 1) trigger = causes[0];
|
|
251
255
|
else trigger = "mixed";
|
|
252
|
-
const
|
|
256
|
+
const nothingChanged = input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0;
|
|
257
|
+
const cacheHit = input.compiled === true && trigger === "parent" && input.propChanges.length === 0;
|
|
258
|
+
const revealed = input.revealed === true && trigger === "parent";
|
|
259
|
+
const avoidable = trigger === "parent" && !cacheHit && !revealed;
|
|
253
260
|
const reasons = [];
|
|
254
|
-
|
|
261
|
+
const memoFix = input.classComponent ? `Make "${input.component}" extend PureComponent (or implement shouldComponentUpdate)` : `Wrap "${input.component}" in React.memo (or extend PureComponent)`;
|
|
262
|
+
if (cacheHit) {
|
|
263
|
+
reasons.push(`compiled by React Compiler: its output is memoized and the inputs did not change, so this render is cheap; nothing to fix.`);
|
|
264
|
+
} else if (revealed) {
|
|
265
|
+
reasons.push(
|
|
266
|
+
`re-rendered because the Suspense boundary above it switched from its fallback to content: React re-renders the content it kept hidden while the fallback was shown. A one-off; nothing to fix.`
|
|
267
|
+
);
|
|
268
|
+
} else if (avoidable && nothingChanged) {
|
|
255
269
|
const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
|
|
256
|
-
reasons.push(`re-rendered with identical props because ${who}.
|
|
270
|
+
reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
|
|
257
271
|
} else if (avoidable && input.parent) {
|
|
258
272
|
reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
|
|
259
273
|
}
|
|
@@ -262,9 +276,9 @@ function buildReport(input) {
|
|
|
262
276
|
reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
|
|
263
277
|
}
|
|
264
278
|
const memoized = input.memoized !== false;
|
|
265
|
-
if (avoidable && !memoized && input.propChanges.length > 0) {
|
|
279
|
+
if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
|
|
266
280
|
reasons.push(
|
|
267
|
-
`"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also wrap it in React.memo (or extend PureComponent).`
|
|
281
|
+
`"${input.component}" is not memoized, so fixing the props alone will not stop this re-render: also ${input.classComponent ? "extend PureComponent (or implement shouldComponentUpdate)" : "wrap it in React.memo (or extend PureComponent)"}.`
|
|
268
282
|
);
|
|
269
283
|
}
|
|
270
284
|
for (const c of stateChanges) {
|
|
@@ -276,6 +290,8 @@ function buildReport(input) {
|
|
|
276
290
|
const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
|
|
277
291
|
const keys = c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys ? `: only ${c.changedKeys.map((k) => `"${k}"`).join(", ")} of ${c.totalKeys} keys changed, yet every consumer re-renders. Split the context or memoize the slices consumers read` : "";
|
|
278
292
|
reasons.push(`${c.path} changed${where}${keys}.`);
|
|
293
|
+
} else if (c.hook === "useDeferredValue" && isGenuine(c)) {
|
|
294
|
+
reasons.push(`${hookLabel(c)} caught up with its latest value: this is the deferred second render React schedules after the urgent one; nothing to fix.`);
|
|
279
295
|
} else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
|
|
280
296
|
else if (isStateHook(c))
|
|
281
297
|
reasons.push(`${hookLabel(c)} was set to a value deep-equal to the current one (new reference, same contents): reuse the existing object or bail out before calling the setter.`);
|
|
@@ -306,6 +322,7 @@ function buildReport(input) {
|
|
|
306
322
|
reasons,
|
|
307
323
|
time: now()
|
|
308
324
|
};
|
|
325
|
+
if (input.compiled) report.compiled = true;
|
|
309
326
|
if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
|
|
310
327
|
if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
|
|
311
328
|
if (input.commitPriority) report.commitPriority = input.commitPriority;
|
|
@@ -343,10 +360,10 @@ var KIND_LABEL = {
|
|
|
343
360
|
};
|
|
344
361
|
function summarize(report) {
|
|
345
362
|
const counts2 = /* @__PURE__ */ new Map();
|
|
346
|
-
for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
|
|
363
|
+
for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
|
|
347
364
|
counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
|
|
348
365
|
}
|
|
349
|
-
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
|
|
366
|
+
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
|
|
350
367
|
if (parts.length === 0) parts.push("no changes");
|
|
351
368
|
return parts.join(", ");
|
|
352
369
|
}
|
|
@@ -368,16 +385,27 @@ function printReport(report, options) {
|
|
|
368
385
|
}
|
|
369
386
|
|
|
370
387
|
// src/state.ts
|
|
388
|
+
var MAX_SEEN_COMPONENTS = 500;
|
|
371
389
|
var KEY = /* @__PURE__ */ Symbol.for("rerender-lens.state");
|
|
372
390
|
function getState() {
|
|
373
391
|
const g = globalThis;
|
|
374
392
|
let s = g[KEY];
|
|
375
393
|
if (!s) {
|
|
376
|
-
s = { options: {}, enabled: false, printed: /* @__PURE__ */ new Map(), detach: null, nextInstanceId: 1, nextCommitId: 1, warnedOnce: /* @__PURE__ */ new Set(), scheduled: 0, commits: 0, overheadMs: 0, maxCommitMs: 0, truncated: 0 };
|
|
394
|
+
s = { options: {}, enabled: false, printed: /* @__PURE__ */ new Map(), detach: null, nextInstanceId: 1, nextCommitId: 1, warnedOnce: /* @__PURE__ */ new Set(), scheduled: 0, commits: 0, overheadMs: 0, maxCommitMs: 0, truncated: 0, seen: /* @__PURE__ */ new Set(), seenTracked: /* @__PURE__ */ new Set(), seenOverflow: false };
|
|
377
395
|
g[KEY] = s;
|
|
378
396
|
}
|
|
379
397
|
return s;
|
|
380
398
|
}
|
|
399
|
+
function noteRendered(s, name, tracked) {
|
|
400
|
+
if (!s.seen.has(name)) {
|
|
401
|
+
if (s.seen.size >= MAX_SEEN_COMPONENTS) {
|
|
402
|
+
s.seenOverflow = true;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
s.seen.add(name);
|
|
406
|
+
}
|
|
407
|
+
if (tracked) s.seenTracked.add(name);
|
|
408
|
+
}
|
|
381
409
|
function warnOnce(key, message) {
|
|
382
410
|
const s = getState();
|
|
383
411
|
if (s.warnedOnce.has(key)) return;
|
|
@@ -455,6 +483,9 @@ function init(options = {}) {
|
|
|
455
483
|
const s = getState();
|
|
456
484
|
s.options = { ...options };
|
|
457
485
|
s.printed.clear();
|
|
486
|
+
s.seen.clear();
|
|
487
|
+
s.seenTracked.clear();
|
|
488
|
+
s.seenOverflow = false;
|
|
458
489
|
if (!s.enabled) {
|
|
459
490
|
s.detach = attach();
|
|
460
491
|
s.enabled = true;
|
|
@@ -470,6 +501,27 @@ function disable() {
|
|
|
470
501
|
}
|
|
471
502
|
|
|
472
503
|
// src/hookNames.ts
|
|
504
|
+
var NODE_COUNT = {
|
|
505
|
+
useState: 1,
|
|
506
|
+
useReducer: 1,
|
|
507
|
+
useRef: 1,
|
|
508
|
+
useMemo: 1,
|
|
509
|
+
useCallback: 1,
|
|
510
|
+
useEffect: 1,
|
|
511
|
+
useLayoutEffect: 1,
|
|
512
|
+
useInsertionEffect: 1,
|
|
513
|
+
useImperativeHandle: 1,
|
|
514
|
+
useSyncExternalStore: 2,
|
|
515
|
+
useTransition: 2,
|
|
516
|
+
useDeferredValue: 1,
|
|
517
|
+
useId: 1,
|
|
518
|
+
useOptimistic: 1,
|
|
519
|
+
useActionState: 3,
|
|
520
|
+
useFormState: 3,
|
|
521
|
+
useEffectEvent: 1,
|
|
522
|
+
useCacheRefresh: 1
|
|
523
|
+
};
|
|
524
|
+
var BUILTIN_HOOKS = /* @__PURE__ */ new Set([...Object.keys(NODE_COUNT), "useContext", "useDebugValue", "use"]);
|
|
473
525
|
var noop = () => {
|
|
474
526
|
};
|
|
475
527
|
function customHooksFromStack(stack, componentName, marker) {
|
|
@@ -484,7 +536,7 @@ function customHooksFromStack(stack, componentName, marker) {
|
|
|
484
536
|
continue;
|
|
485
537
|
}
|
|
486
538
|
if (name === componentName || name.endsWith("." + componentName)) break;
|
|
487
|
-
if (/^use[A-Z0-9_]/.test(name)) names.push(name);
|
|
539
|
+
if (/^use[A-Z0-9_]/.test(name) && !BUILTIN_HOOKS.has(name)) names.push(name);
|
|
488
540
|
if (names.length > 8) break;
|
|
489
541
|
}
|
|
490
542
|
return names;
|
|
@@ -768,6 +820,11 @@ function isMemoizedFiber(fiber) {
|
|
|
768
820
|
}
|
|
769
821
|
return false;
|
|
770
822
|
}
|
|
823
|
+
function isCompiledFiber(fiber) {
|
|
824
|
+
if (fiber.tag === ClassComponent) return false;
|
|
825
|
+
const cache2 = fiber.updateQueue?.memoCache;
|
|
826
|
+
return !!cache2 && Array.isArray(cache2.data);
|
|
827
|
+
}
|
|
771
828
|
function durationsOf(fiber) {
|
|
772
829
|
if (typeof fiber.actualDuration !== "number") return null;
|
|
773
830
|
const tree = fiber.actualDuration;
|
|
@@ -920,7 +977,8 @@ function diffHooks(fiber, alt) {
|
|
|
920
977
|
const labels = hookLabelsFor(fiber, b);
|
|
921
978
|
let i = 0;
|
|
922
979
|
while (a && b) {
|
|
923
|
-
|
|
980
|
+
const deferred = labels?.[i] === "useDeferredValue";
|
|
981
|
+
if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
|
|
924
982
|
const hook = labels?.[i] ?? hookLabel2(b);
|
|
925
983
|
out.push({
|
|
926
984
|
path: `${hook}#${i}`,
|
|
@@ -954,6 +1012,10 @@ function diffContexts(fiber, alt) {
|
|
|
954
1012
|
let a = alt.dependencies?.firstContext ?? null;
|
|
955
1013
|
let b = fiber.dependencies?.firstContext ?? null;
|
|
956
1014
|
let i = 0;
|
|
1015
|
+
if (b && !("memoizedValue" in b)) {
|
|
1016
|
+
warnOnce("contextValues", "this React does not record context values on the fiber (React < 18): context changes are reported as plain parent re-renders.");
|
|
1017
|
+
return out;
|
|
1018
|
+
}
|
|
957
1019
|
while (a && b) {
|
|
958
1020
|
if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
|
|
959
1021
|
const name = b.context.displayName ?? "Context";
|
|
@@ -986,10 +1048,26 @@ function diffContexts(fiber, alt) {
|
|
|
986
1048
|
return out;
|
|
987
1049
|
}
|
|
988
1050
|
var isGenuine2 = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
1051
|
+
var isRefObject2 = (v) => {
|
|
1052
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1053
|
+
const keys = Object.keys(v);
|
|
1054
|
+
return keys.length === 1 && keys[0] === "current";
|
|
1055
|
+
};
|
|
1056
|
+
function diffProps(prev, next) {
|
|
1057
|
+
if (prev === next) return [];
|
|
1058
|
+
const pr = prev.ref;
|
|
1059
|
+
const nr = next.ref;
|
|
1060
|
+
if (pr !== nr && isRefObject2(pr) && isRefObject2(nr)) {
|
|
1061
|
+
const { ref: _p, ...restPrev } = prev;
|
|
1062
|
+
const { ref: _n, ...restNext } = next;
|
|
1063
|
+
return [{ path: "ref", kind: "deep-equal", prev: pr, next: nr }, ...diffRecords(restPrev, restNext)];
|
|
1064
|
+
}
|
|
1065
|
+
return diffRecords(prev, next);
|
|
1066
|
+
}
|
|
989
1067
|
function analyze(fiber, alt, trackHooks) {
|
|
990
1068
|
const prevProps = alt.memoizedProps ?? {};
|
|
991
1069
|
const nextProps = fiber.memoizedProps ?? {};
|
|
992
|
-
const propChanges =
|
|
1070
|
+
const propChanges = diffProps(prevProps, nextProps);
|
|
993
1071
|
let stateChanges = [];
|
|
994
1072
|
let hookChanges = [];
|
|
995
1073
|
if (fiber.tag === ClassComponent) {
|
|
@@ -1002,7 +1080,7 @@ function analyze(fiber, alt, trackHooks) {
|
|
|
1002
1080
|
} else if (trackHooks) {
|
|
1003
1081
|
hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
|
|
1004
1082
|
}
|
|
1005
|
-
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
|
|
1083
|
+
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
|
|
1006
1084
|
const causes = [];
|
|
1007
1085
|
if (propChanges.some(isGenuine2)) causes.push("props");
|
|
1008
1086
|
if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
|
|
@@ -1042,6 +1120,24 @@ var lastCommit = null;
|
|
|
1042
1120
|
var EFFECT_LOOP_WINDOW_MS = 50;
|
|
1043
1121
|
var MAX_REPORTS_PER_COMMIT = 200;
|
|
1044
1122
|
var COMMIT_TIME_BUDGET_MS = 25;
|
|
1123
|
+
function isDeferredCatchUp(fiber) {
|
|
1124
|
+
const types = fiber._debugHookTypes;
|
|
1125
|
+
if (!types || !types.includes("useDeferredValue") || !fiber.alternate) return false;
|
|
1126
|
+
const first = fiber.memoizedState;
|
|
1127
|
+
if (!isHookList(first)) return false;
|
|
1128
|
+
const labels = hookLabelsFor(fiber, first);
|
|
1129
|
+
if (!labels) return false;
|
|
1130
|
+
let a = fiber.alternate.memoizedState;
|
|
1131
|
+
let b = first;
|
|
1132
|
+
for (let i = 0; a && b; a = a.next, b = b.next, i++) {
|
|
1133
|
+
if (labels[i] === "useDeferredValue" && !Object.is(a.memoizedState, b.memoizedState)) return true;
|
|
1134
|
+
}
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
function isUnder(fiber, boundaries) {
|
|
1138
|
+
for (let f = fiber.return; f && f.tag !== HostRoot; f = f.return) if (f.tag === SuspenseComponent && boundaries.includes(f)) return true;
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1045
1141
|
function onCommit(root, commitPriority) {
|
|
1046
1142
|
const s = getState();
|
|
1047
1143
|
if (!s.enabled) return;
|
|
@@ -1051,17 +1147,20 @@ function onCommit(root, commitPriority) {
|
|
|
1051
1147
|
const rendered = [];
|
|
1052
1148
|
const renderedNames = /* @__PURE__ */ new Set();
|
|
1053
1149
|
let hot = false;
|
|
1054
|
-
let
|
|
1150
|
+
let resolvedBoundaries = null;
|
|
1055
1151
|
const stack = [root.current];
|
|
1056
1152
|
while (stack.length) {
|
|
1057
1153
|
const fiber = stack.pop();
|
|
1058
1154
|
const alt = fiber.alternate;
|
|
1059
1155
|
if (alt && isComponentTag(fiber.tag) && didRender(fiber)) {
|
|
1060
1156
|
if (isHotSwapped(fiber, alt)) hot = true;
|
|
1061
|
-
|
|
1062
|
-
|
|
1157
|
+
const name = fiberName(fiber);
|
|
1158
|
+
renderedNames.add(name);
|
|
1159
|
+
const tracked = shouldTrack(fiberType(fiber), o);
|
|
1160
|
+
noteRendered(s, name, tracked);
|
|
1161
|
+
if (tracked) rendered.push(fiber);
|
|
1063
1162
|
}
|
|
1064
|
-
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null)
|
|
1163
|
+
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
|
|
1065
1164
|
if (!alt || fiber.child !== alt.child) {
|
|
1066
1165
|
for (let c = fiber.child; c; c = c.sibling) stack.push(c);
|
|
1067
1166
|
}
|
|
@@ -1073,10 +1172,10 @@ function onCommit(root, commitPriority) {
|
|
|
1073
1172
|
let commitCause;
|
|
1074
1173
|
let afterCommit;
|
|
1075
1174
|
const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
|
|
1076
|
-
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
|
|
1175
|
+
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
|
|
1077
1176
|
commitCause = "effect-after-commit";
|
|
1078
1177
|
afterCommit = lastCommit.id;
|
|
1079
|
-
} else if (
|
|
1178
|
+
} else if (resolvedBoundaries) commitCause = "suspense-resolved";
|
|
1080
1179
|
if (rendered.length === 0) {
|
|
1081
1180
|
lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
|
|
1082
1181
|
return;
|
|
@@ -1128,6 +1227,9 @@ function onCommit(root, commitPriority) {
|
|
|
1128
1227
|
owner: ownerName(fiber),
|
|
1129
1228
|
path: componentPath(fiber),
|
|
1130
1229
|
memoized: isMemoizedFiber(fiber),
|
|
1230
|
+
compiled: isCompiledFiber(fiber),
|
|
1231
|
+
classComponent: fiber.tag === ClassComponent,
|
|
1232
|
+
revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
|
|
1131
1233
|
selfDuration: durations ? durations.self : void 0,
|
|
1132
1234
|
treeDuration: durations ? durations.tree : void 0,
|
|
1133
1235
|
commitId,
|
|
@@ -1141,38 +1243,203 @@ function onCommit(root, commitPriority) {
|
|
|
1141
1243
|
}
|
|
1142
1244
|
}
|
|
1143
1245
|
|
|
1246
|
+
// src/causes.ts
|
|
1247
|
+
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);
|
|
1248
|
+
var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
|
|
1249
|
+
function indexByComponent(reports) {
|
|
1250
|
+
const index = /* @__PURE__ */ new Map();
|
|
1251
|
+
for (const r of reports) {
|
|
1252
|
+
const list = index.get(r.component);
|
|
1253
|
+
if (list) list.push(r);
|
|
1254
|
+
else index.set(r.component, [r]);
|
|
1255
|
+
}
|
|
1256
|
+
return index;
|
|
1257
|
+
}
|
|
1258
|
+
function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
|
|
1259
|
+
let cur = r;
|
|
1260
|
+
const seen = /* @__PURE__ */ new Set([r]);
|
|
1261
|
+
while (cur.trigger === "parent" && cur.parent) {
|
|
1262
|
+
const parent = cur.parent;
|
|
1263
|
+
const candidates = index.get(parent.name);
|
|
1264
|
+
const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
|
|
1265
|
+
if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
|
|
1266
|
+
seen.add(p);
|
|
1267
|
+
cur = p;
|
|
1268
|
+
}
|
|
1269
|
+
return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
|
|
1270
|
+
}
|
|
1271
|
+
function rootCausesOf(reports) {
|
|
1272
|
+
const roots = /* @__PURE__ */ new Map();
|
|
1273
|
+
const rootByReport = /* @__PURE__ */ new Map();
|
|
1274
|
+
const index = indexByComponent(reports);
|
|
1275
|
+
let avoidable = 0;
|
|
1276
|
+
let wasted = 0;
|
|
1277
|
+
for (const r of reports) {
|
|
1278
|
+
if (!r.avoidable) continue;
|
|
1279
|
+
avoidable++;
|
|
1280
|
+
if (typeof r.selfDuration === "number") wasted += r.selfDuration;
|
|
1281
|
+
const root = rootCauseOf(r, reports, index);
|
|
1282
|
+
const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
|
|
1283
|
+
const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
|
|
1284
|
+
rootByReport.set(r, name);
|
|
1285
|
+
let agg = roots.get(name);
|
|
1286
|
+
if (!agg) {
|
|
1287
|
+
agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1288
|
+
roots.set(name, agg);
|
|
1289
|
+
}
|
|
1290
|
+
agg.count++;
|
|
1291
|
+
bump(agg.components, r.component);
|
|
1292
|
+
}
|
|
1293
|
+
return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
|
|
1294
|
+
}
|
|
1295
|
+
function groupByCommit(reports) {
|
|
1296
|
+
const out = /* @__PURE__ */ new Map();
|
|
1297
|
+
for (const r of reports) {
|
|
1298
|
+
if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
|
|
1299
|
+
const list = out.get(r.commitId);
|
|
1300
|
+
if (list) list.push(r);
|
|
1301
|
+
else out.set(r.commitId, [r]);
|
|
1302
|
+
}
|
|
1303
|
+
return out;
|
|
1304
|
+
}
|
|
1305
|
+
function rankRootCauses(reports) {
|
|
1306
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1307
|
+
for (const commit of groupByCommit(reports).values()) {
|
|
1308
|
+
for (const root of rootCausesOf(commit).roots) {
|
|
1309
|
+
let agg = byName.get(root.name);
|
|
1310
|
+
if (!agg) {
|
|
1311
|
+
agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1312
|
+
byName.set(root.name, agg);
|
|
1313
|
+
}
|
|
1314
|
+
agg.commits++;
|
|
1315
|
+
agg.count += root.count;
|
|
1316
|
+
for (const [c, n] of root.components) bump(agg.components, c, n);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
|
|
1320
|
+
}
|
|
1321
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1322
|
+
function formatRootCauses(reports, limit = 5) {
|
|
1323
|
+
const ranked = rankRootCauses(reports);
|
|
1324
|
+
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(", ")})`);
|
|
1325
|
+
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1326
|
+
return lines.join("\n");
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1144
1329
|
// src/fixes.ts
|
|
1145
|
-
var
|
|
1330
|
+
var MAX_FIX_REPORTS = 50;
|
|
1146
1331
|
var rootOf = (path) => path.split(/[.[]/)[0] || path;
|
|
1332
|
+
var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
|
|
1333
|
+
function shortValue(v, max = 60) {
|
|
1334
|
+
let s;
|
|
1335
|
+
try {
|
|
1336
|
+
s = JSON.stringify(v);
|
|
1337
|
+
} catch {
|
|
1338
|
+
s = String(v);
|
|
1339
|
+
}
|
|
1340
|
+
if (s === void 0) s = String(v);
|
|
1341
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
1342
|
+
}
|
|
1147
1343
|
function fixesFor(r) {
|
|
1148
1344
|
const out = [];
|
|
1149
|
-
const
|
|
1150
|
-
const
|
|
1345
|
+
const ownerName2 = r.owner || r.parent && r.parent.name || null;
|
|
1346
|
+
const propChanges = r.propChanges || [];
|
|
1347
|
+
const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
|
|
1151
1348
|
if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
|
|
1349
|
+
const identical = changes.length === 0;
|
|
1152
1350
|
out.push({
|
|
1153
1351
|
kind: "memo",
|
|
1154
1352
|
owner: r.component,
|
|
1155
1353
|
target: r.component,
|
|
1156
1354
|
prop: null,
|
|
1157
1355
|
label: `Wrap <${r.component}> in React.memo`,
|
|
1158
|
-
detail:
|
|
1356
|
+
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.`,
|
|
1357
|
+
snippet: `// ${r.component}
|
|
1358
|
+
import { memo } from 'react';
|
|
1359
|
+
|
|
1360
|
+
export const ${r.component} = memo(function ${r.component}(props) {
|
|
1361
|
+
// ...
|
|
1362
|
+
});
|
|
1363
|
+
// class components: extend PureComponent instead`
|
|
1159
1364
|
});
|
|
1160
1365
|
}
|
|
1161
|
-
for (const c of
|
|
1162
|
-
if (!
|
|
1366
|
+
for (const c of propChanges) {
|
|
1367
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1368
|
+
const owner = ownerName2 || "?";
|
|
1163
1369
|
const root = rootOf(c.path);
|
|
1370
|
+
const id = identifier(root);
|
|
1164
1371
|
if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
|
|
1165
|
-
out.push({
|
|
1372
|
+
out.push({
|
|
1373
|
+
kind: "children",
|
|
1374
|
+
owner,
|
|
1375
|
+
target: r.component,
|
|
1376
|
+
prop: "children",
|
|
1377
|
+
label: `memoize children of <${r.component}> in <${owner}>`,
|
|
1378
|
+
detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
|
|
1379
|
+
snippet: `// ${owner}
|
|
1380
|
+
import { useMemo } from 'react';
|
|
1381
|
+
|
|
1382
|
+
const children = useMemo(() => (
|
|
1383
|
+
<>{/* the same elements */}</>
|
|
1384
|
+
), [/* deps */]);
|
|
1385
|
+
|
|
1386
|
+
<${r.component}>{children}</${r.component}>
|
|
1387
|
+
|
|
1388
|
+
// static children: hoist them to module scope
|
|
1389
|
+
const STATIC = <em>hi</em>;`
|
|
1390
|
+
});
|
|
1166
1391
|
} else if (c.kind === "function") {
|
|
1167
|
-
out.push({
|
|
1392
|
+
out.push({
|
|
1393
|
+
kind: "useCallback",
|
|
1394
|
+
owner,
|
|
1395
|
+
target: r.component,
|
|
1396
|
+
prop: root,
|
|
1397
|
+
label: `useCallback(${root}) in <${owner}>`,
|
|
1398
|
+
detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
|
|
1399
|
+
snippet: `// ${owner}
|
|
1400
|
+
import { useCallback } from 'react';
|
|
1401
|
+
|
|
1402
|
+
const ${id} = useCallback((/* args */) => {
|
|
1403
|
+
// ...
|
|
1404
|
+
}, [/* deps */]);
|
|
1405
|
+
|
|
1406
|
+
<${r.component} ${root}={${id}} />`
|
|
1407
|
+
});
|
|
1168
1408
|
} else if (c.kind === "element") {
|
|
1169
|
-
out.push({
|
|
1409
|
+
out.push({
|
|
1410
|
+
kind: "useMemoElement",
|
|
1411
|
+
owner,
|
|
1412
|
+
target: r.component,
|
|
1413
|
+
prop: root,
|
|
1414
|
+
label: `memoize element prop ${root} in <${owner}>`,
|
|
1415
|
+
detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
|
|
1416
|
+
snippet: `// ${owner}
|
|
1417
|
+
import { useMemo } from 'react';
|
|
1418
|
+
|
|
1419
|
+
const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
|
|
1420
|
+
// or pass it as children from a component that does not re-render`
|
|
1421
|
+
});
|
|
1170
1422
|
} else {
|
|
1171
|
-
|
|
1423
|
+
const isArray = Array.isArray(c.next);
|
|
1424
|
+
out.push({
|
|
1425
|
+
kind: "useMemo",
|
|
1426
|
+
owner,
|
|
1427
|
+
target: r.component,
|
|
1428
|
+
prop: root,
|
|
1429
|
+
label: `useMemo(${root}) in <${owner}>`,
|
|
1430
|
+
detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
|
|
1431
|
+
snippet: `// ${owner}
|
|
1432
|
+
import { useMemo } from 'react';
|
|
1433
|
+
|
|
1434
|
+
const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
|
|
1435
|
+
|
|
1436
|
+
// or, when it never changes, hoist it to module scope:
|
|
1437
|
+
const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
|
|
1438
|
+
});
|
|
1172
1439
|
}
|
|
1173
1440
|
}
|
|
1174
|
-
for (const c of [...r.stateChanges, ...r.hookChanges]) {
|
|
1175
|
-
const isContext = c.hook === "useContext";
|
|
1441
|
+
for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
|
|
1442
|
+
const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
|
|
1176
1443
|
const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
|
|
1177
1444
|
const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
|
|
1178
1445
|
if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
|
|
@@ -1182,20 +1449,63 @@ function fixesFor(r) {
|
|
|
1182
1449
|
target: r.component,
|
|
1183
1450
|
prop: ctxName,
|
|
1184
1451
|
label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
|
|
1185
|
-
detail: `${c.changedKeys.
|
|
1452
|
+
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.`,
|
|
1453
|
+
snippet: `// ${providerOwner || "Provider"}
|
|
1454
|
+
// one context per independently-changing slice
|
|
1455
|
+
const ${identifier(ctxName)}Static = createContext(...);
|
|
1456
|
+
const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
|
|
1457
|
+
|
|
1458
|
+
// or keep one context and let consumers select a slice:
|
|
1459
|
+
const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
|
|
1186
1460
|
});
|
|
1187
1461
|
continue;
|
|
1188
1462
|
}
|
|
1189
|
-
if (!
|
|
1463
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1190
1464
|
if (isContext) {
|
|
1191
|
-
out.push({
|
|
1465
|
+
out.push({
|
|
1466
|
+
kind: "contextValue",
|
|
1467
|
+
owner: providerOwner || `${ctxName}.Provider`,
|
|
1468
|
+
target: r.component,
|
|
1469
|
+
prop: ctxName,
|
|
1470
|
+
label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
|
|
1471
|
+
detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
|
|
1472
|
+
snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
|
|
1473
|
+
const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
|
|
1474
|
+
<${ctxName}.Provider value={value}>`
|
|
1475
|
+
});
|
|
1192
1476
|
} else if (c.hook === "useSyncExternalStore") {
|
|
1193
1477
|
const chain = c.custom || [];
|
|
1194
1478
|
const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
|
|
1195
1479
|
const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
|
|
1196
|
-
|
|
1480
|
+
const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
|
|
1481
|
+
out.push({
|
|
1482
|
+
kind: "storeSnapshot",
|
|
1483
|
+
owner: r.component,
|
|
1484
|
+
target: r.component,
|
|
1485
|
+
prop: c.path,
|
|
1486
|
+
label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
|
|
1487
|
+
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.`,
|
|
1488
|
+
snippet: redux ? `// ${r.component}
|
|
1489
|
+
import { shallowEqual } from 'react-redux';
|
|
1490
|
+
const slice = useSelector(selectSlice, shallowEqual);
|
|
1491
|
+
// or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
|
|
1492
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
1493
|
+
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
1494
|
+
// or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
|
|
1495
|
+
// getSnapshot must return the same reference while the data is unchanged
|
|
1496
|
+
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
|
|
1497
|
+
});
|
|
1197
1498
|
} else {
|
|
1198
|
-
out.push({
|
|
1499
|
+
out.push({
|
|
1500
|
+
kind: "bailout",
|
|
1501
|
+
owner: r.component,
|
|
1502
|
+
target: r.component,
|
|
1503
|
+
prop: c.path,
|
|
1504
|
+
label: `bail out before setting ${c.path} in <${r.component}>`,
|
|
1505
|
+
detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
|
|
1506
|
+
snippet: `// ${r.component}
|
|
1507
|
+
setState((prev) => (deepEqual(prev, next) ? prev : next));`
|
|
1508
|
+
});
|
|
1199
1509
|
}
|
|
1200
1510
|
}
|
|
1201
1511
|
return out;
|
|
@@ -1209,11 +1519,12 @@ function rankFixes(reports) {
|
|
|
1209
1519
|
const key = fixKey(f);
|
|
1210
1520
|
let agg = byKey.get(key);
|
|
1211
1521
|
if (!agg) {
|
|
1212
|
-
agg = { ...f, key, count: 0, components: {} };
|
|
1522
|
+
agg = { ...f, key, count: 0, components: {}, reports: [] };
|
|
1213
1523
|
byKey.set(key, agg);
|
|
1214
1524
|
}
|
|
1215
1525
|
agg.count++;
|
|
1216
1526
|
agg.components[r.component] = (agg.components[r.component] || 0) + 1;
|
|
1527
|
+
if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
|
|
1217
1528
|
}
|
|
1218
1529
|
}
|
|
1219
1530
|
return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
@@ -1223,6 +1534,8 @@ function formatFixes(reports, limit = 10) {
|
|
|
1223
1534
|
if (!ranked.length) return "No avoidable re-renders.";
|
|
1224
1535
|
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(", ")})`);
|
|
1225
1536
|
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1537
|
+
const roots = formatRootCauses(reports, 5);
|
|
1538
|
+
if (roots) lines.push("", "Root causes:", roots);
|
|
1226
1539
|
return lines.join("\n");
|
|
1227
1540
|
}
|
|
1228
1541
|
|
|
@@ -1321,7 +1634,7 @@ ${formatFixes(reports)}`);
|
|
|
1321
1634
|
};
|
|
1322
1635
|
}
|
|
1323
1636
|
|
|
1324
|
-
// src/
|
|
1637
|
+
// src/runner-report.ts
|
|
1325
1638
|
var OUT_ENV = "RERENDER_LENS_OUT";
|
|
1326
1639
|
function setupRerenderLens(options = {}, hooks = {}) {
|
|
1327
1640
|
ensureDevtoolsHook();
|
|
@@ -1356,49 +1669,55 @@ function slim(r) {
|
|
|
1356
1669
|
state: void 0
|
|
1357
1670
|
};
|
|
1358
1671
|
}
|
|
1359
|
-
|
|
1672
|
+
function createRunFile() {
|
|
1673
|
+
const dir = join(tmpdir(), "rerender-lens");
|
|
1674
|
+
mkdirSync(dir, { recursive: true });
|
|
1675
|
+
const file = join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
|
|
1676
|
+
writeFileSync(file, "");
|
|
1677
|
+
process.env[OUT_ENV] = file;
|
|
1678
|
+
return file;
|
|
1679
|
+
}
|
|
1680
|
+
function finishRun(file, options = {}) {
|
|
1681
|
+
const reports = readRun(file);
|
|
1682
|
+
const log = options.write ?? ((line) => void process.stdout.write(line + "\n"));
|
|
1683
|
+
const avoidable = reports.filter((r) => r.avoidable).length;
|
|
1684
|
+
let error = null;
|
|
1685
|
+
log("");
|
|
1686
|
+
log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
|
|
1687
|
+
if (avoidable) log(formatFixes(reports, options.limit ?? 10));
|
|
1688
|
+
if (options.exportTo) writeFileSync(options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
|
|
1689
|
+
if (options.budget) {
|
|
1690
|
+
const budget = JSON.parse(readFileSync(options.budget, "utf8"));
|
|
1691
|
+
const result = checkBudget(reports, budget);
|
|
1692
|
+
if (!result.ok) {
|
|
1693
|
+
const lines = result.violations.map((v) => ` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
|
|
1694
|
+
for (const line of lines) log(line);
|
|
1695
|
+
error = new Error(`rerender-lens: re-render budget exceeded (${options.budget})
|
|
1696
|
+
${lines.join("\n")}`);
|
|
1697
|
+
} else log(` within budget (${options.budget})`);
|
|
1698
|
+
}
|
|
1699
|
+
try {
|
|
1700
|
+
rmSync(file, { force: true });
|
|
1701
|
+
} catch {
|
|
1702
|
+
}
|
|
1703
|
+
if (process.env[OUT_ENV] === file) delete process.env[OUT_ENV];
|
|
1704
|
+
return { reports, error };
|
|
1705
|
+
}
|
|
1706
|
+
var RunReporterCore = class {
|
|
1360
1707
|
constructor(options = {}) {
|
|
1361
1708
|
this.done = false;
|
|
1362
1709
|
this.options = options;
|
|
1363
|
-
|
|
1364
|
-
mkdirSync(dir, { recursive: true });
|
|
1365
|
-
this.file = join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
|
|
1366
|
-
writeFileSync(this.file, "");
|
|
1367
|
-
process.env[OUT_ENV] = this.file;
|
|
1710
|
+
this.file = createRunFile();
|
|
1368
1711
|
}
|
|
1369
|
-
|
|
1712
|
+
/** Publish the path again (the runner may have replaced `process.env` since the constructor ran). */
|
|
1713
|
+
arm() {
|
|
1370
1714
|
process.env[OUT_ENV] = this.file;
|
|
1371
1715
|
}
|
|
1372
|
-
|
|
1373
|
-
this.finish();
|
|
1374
|
-
}
|
|
1375
|
-
/** Vitest < 3 name for the same event. */
|
|
1376
|
-
onFinished() {
|
|
1377
|
-
this.finish();
|
|
1378
|
-
}
|
|
1716
|
+
/** The run's result, or null when the run was already finished (runners fire two "done" events). */
|
|
1379
1717
|
finish() {
|
|
1380
|
-
if (this.done) return;
|
|
1718
|
+
if (this.done) return null;
|
|
1381
1719
|
this.done = true;
|
|
1382
|
-
|
|
1383
|
-
const log = this.options.write ?? ((line) => void process.stdout.write(line + "\n"));
|
|
1384
|
-
const avoidable = reports.filter((r) => r.avoidable).length;
|
|
1385
|
-
log("");
|
|
1386
|
-
log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
|
|
1387
|
-
if (avoidable) log(formatFixes(reports, this.options.limit ?? 10));
|
|
1388
|
-
if (this.options.exportTo) writeFileSync(this.options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
|
|
1389
|
-
if (this.options.budget) {
|
|
1390
|
-
const budget = JSON.parse(readFileSync(this.options.budget, "utf8"));
|
|
1391
|
-
const result = checkBudget(reports, budget);
|
|
1392
|
-
if (!result.ok) {
|
|
1393
|
-
for (const v of result.violations) log(` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
|
|
1394
|
-
process.exitCode = 1;
|
|
1395
|
-
} else log(` within budget (${this.options.budget})`);
|
|
1396
|
-
}
|
|
1397
|
-
try {
|
|
1398
|
-
rmSync(this.file, { force: true });
|
|
1399
|
-
} catch {
|
|
1400
|
-
}
|
|
1401
|
-
if (process.env[OUT_ENV] === this.file) delete process.env[OUT_ENV];
|
|
1720
|
+
return finishRun(this.file, this.options);
|
|
1402
1721
|
}
|
|
1403
1722
|
};
|
|
1404
1723
|
function readRun(file) {
|
|
@@ -1415,6 +1734,27 @@ function readRun(file) {
|
|
|
1415
1734
|
return out;
|
|
1416
1735
|
}
|
|
1417
1736
|
|
|
1737
|
+
// src/vitest.ts
|
|
1738
|
+
var RerenderLensReporter = class {
|
|
1739
|
+
constructor(options = {}) {
|
|
1740
|
+
this.core = new RunReporterCore(options);
|
|
1741
|
+
}
|
|
1742
|
+
onInit() {
|
|
1743
|
+
this.core.arm();
|
|
1744
|
+
}
|
|
1745
|
+
onTestRunEnd() {
|
|
1746
|
+
this.finish();
|
|
1747
|
+
}
|
|
1748
|
+
/** Vitest < 3 name for the same event. */
|
|
1749
|
+
onFinished() {
|
|
1750
|
+
this.finish();
|
|
1751
|
+
}
|
|
1752
|
+
finish() {
|
|
1753
|
+
const result = this.core.finish();
|
|
1754
|
+
if (result?.error) process.exitCode = 1;
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1418
1758
|
export { OUT_ENV, checkBudget, RerenderLensReporter as default, formatFixes, readRun, setupRerenderLens };
|
|
1419
1759
|
//# sourceMappingURL=vitest.js.map
|
|
1420
1760
|
//# sourceMappingURL=vitest.js.map
|