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.cjs
CHANGED
|
@@ -6,7 +6,7 @@ var fs = require('fs');
|
|
|
6
6
|
var os = require('os');
|
|
7
7
|
var path = require('path');
|
|
8
8
|
|
|
9
|
-
// src/
|
|
9
|
+
// src/runner-report.ts
|
|
10
10
|
|
|
11
11
|
// src/diff.ts
|
|
12
12
|
var REACT_ELEMENT = /* @__PURE__ */ Symbol.for("react.element");
|
|
@@ -130,6 +130,7 @@ function deepEqualObjects(a, b, seen) {
|
|
|
130
130
|
}
|
|
131
131
|
return false;
|
|
132
132
|
}
|
|
133
|
+
var AVOIDABLE_KINDS = /* @__PURE__ */ new Set(["deep-equal", "function", "element"]);
|
|
133
134
|
function classify(prev, next) {
|
|
134
135
|
if (typeof prev === "function" && typeof next === "function") {
|
|
135
136
|
return prev.name === next.name && prev.toString() === next.toString() ? "function" : "different";
|
|
@@ -166,30 +167,30 @@ function diffRecords(prev, next, basePath = "") {
|
|
|
166
167
|
if (Object.is(a, b)) continue;
|
|
167
168
|
const kind = classify(a, b);
|
|
168
169
|
if (kind === "different") {
|
|
169
|
-
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path), kind, prev: a, next: b });
|
|
170
|
+
changes.push({ path: diffBudgetExhausted() ? path : firstDifferentPath(a, b, path) ?? path, kind, prev: a, next: b });
|
|
170
171
|
} else {
|
|
171
172
|
changes.push({ path, kind, prev: a, next: b });
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
return changes;
|
|
175
176
|
}
|
|
176
|
-
function firstDifferentPath(a, b, path, depth = 0) {
|
|
177
|
-
if (depth > 8) return path;
|
|
177
|
+
function firstDifferentPath(a, b, path = "", depth = 0) {
|
|
178
|
+
if (depth > 8) return path || "(value)";
|
|
178
179
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
179
|
-
if (a.length !== b.length) return `${path}.length
|
|
180
|
+
if (a.length !== b.length) return path ? `${path}.length` : "length";
|
|
180
181
|
for (let i = 0; i < a.length; i++) {
|
|
181
182
|
if (!deepEqual(a[i], b[i])) return firstDifferentPath(a[i], b[i], joinPath(path, i), depth + 1);
|
|
182
183
|
}
|
|
183
|
-
return
|
|
184
|
+
return null;
|
|
184
185
|
}
|
|
185
186
|
if (isPlainObject(a) && isPlainObject(b)) {
|
|
186
187
|
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
187
188
|
for (const k of keys) {
|
|
188
189
|
if (!deepEqual(a[k], b[k])) return firstDifferentPath(a[k], b[k], joinPath(path, k), depth + 1);
|
|
189
190
|
}
|
|
190
|
-
return
|
|
191
|
+
return null;
|
|
191
192
|
}
|
|
192
|
-
return path;
|
|
193
|
+
return deepEqual(a, b) ? null : path || "(value)";
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
// src/report.ts
|
|
@@ -207,10 +208,12 @@ function storeAdvice(custom) {
|
|
|
207
208
|
}
|
|
208
209
|
var isGenuine = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
209
210
|
var isChildren = (change) => change.path === "children";
|
|
211
|
+
var isRefObject = (change) => change.path === "ref" && change.kind === "deep-equal";
|
|
210
212
|
function fixFor(change) {
|
|
211
213
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
212
214
|
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`;
|
|
213
215
|
}
|
|
216
|
+
if (isRefObject(change)) return `create the ref once with useRef (or createRef outside the render) and pass the same object every time`;
|
|
214
217
|
switch (change.kind) {
|
|
215
218
|
case "function":
|
|
216
219
|
return `wrap it in useCallback (or hoist it out of the parent's render)`;
|
|
@@ -226,6 +229,7 @@ function describe(change) {
|
|
|
226
229
|
if (isChildren(change) && (change.kind === "element" || change.kind === "deep-equal")) {
|
|
227
230
|
return `children are new React elements with the same types and props on every render of the parent`;
|
|
228
231
|
}
|
|
232
|
+
if (isRefObject(change)) return `prop "ref" is a new ref object on every render (React re-attaches it each time)`;
|
|
229
233
|
switch (change.kind) {
|
|
230
234
|
case "deep-equal":
|
|
231
235
|
return `prop "${change.path}" is a new reference but deep-equal to the previous value`;
|
|
@@ -253,11 +257,21 @@ function buildReport(input) {
|
|
|
253
257
|
if (causes.length === 0) trigger = "parent";
|
|
254
258
|
else if (causes.length === 1) trigger = causes[0];
|
|
255
259
|
else trigger = "mixed";
|
|
256
|
-
const
|
|
260
|
+
const nothingChanged = input.propChanges.length === 0 && stateChanges.length === 0 && hookChanges.length === 0;
|
|
261
|
+
const cacheHit = input.compiled === true && trigger === "parent" && input.propChanges.length === 0;
|
|
262
|
+
const revealed = input.revealed === true && trigger === "parent";
|
|
263
|
+
const avoidable = trigger === "parent" && !cacheHit && !revealed;
|
|
257
264
|
const reasons = [];
|
|
258
|
-
|
|
265
|
+
const memoFix = input.classComponent ? `Make "${input.component}" extend PureComponent (or implement shouldComponentUpdate)` : `Wrap "${input.component}" in React.memo (or extend PureComponent)`;
|
|
266
|
+
if (cacheHit) {
|
|
267
|
+
reasons.push(`compiled by React Compiler: its output is memoized and the inputs did not change, so this render is cheap; nothing to fix.`);
|
|
268
|
+
} else if (revealed) {
|
|
269
|
+
reasons.push(
|
|
270
|
+
`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.`
|
|
271
|
+
);
|
|
272
|
+
} else if (avoidable && nothingChanged) {
|
|
259
273
|
const who = input.parent ? `<${input.parent.name}> re-rendered (${describeTrigger(input.parent.trigger)})` : "its parent re-rendered";
|
|
260
|
-
reasons.push(`re-rendered with identical props because ${who}.
|
|
274
|
+
reasons.push(`re-rendered with identical props because ${who}. ${memoFix}.`);
|
|
261
275
|
} else if (avoidable && input.parent) {
|
|
262
276
|
reasons.push(`caused by <${input.parent.name}> re-rendering (${describeTrigger(input.parent.trigger)}).`);
|
|
263
277
|
}
|
|
@@ -266,9 +280,9 @@ function buildReport(input) {
|
|
|
266
280
|
reasons.push(fix ? `${describe(c)}: ${fix}.` : `${describe(c)}.`);
|
|
267
281
|
}
|
|
268
282
|
const memoized = input.memoized !== false;
|
|
269
|
-
if (avoidable && !memoized && input.propChanges.length > 0) {
|
|
283
|
+
if (avoidable && !memoized && !input.compiled && input.propChanges.length > 0) {
|
|
270
284
|
reasons.push(
|
|
271
|
-
`"${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).`
|
|
285
|
+
`"${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)"}.`
|
|
272
286
|
);
|
|
273
287
|
}
|
|
274
288
|
for (const c of stateChanges) {
|
|
@@ -280,6 +294,8 @@ function buildReport(input) {
|
|
|
280
294
|
const where = c.provider && c.provider.component ? ` (provided by <${c.provider.component}>)` : "";
|
|
281
295
|
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` : "";
|
|
282
296
|
reasons.push(`${c.path} changed${where}${keys}.`);
|
|
297
|
+
} else if (c.hook === "useDeferredValue" && isGenuine(c)) {
|
|
298
|
+
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.`);
|
|
283
299
|
} else if (isGenuine(c)) reasons.push(`${hookLabel(c)} changed.`);
|
|
284
300
|
else if (isStateHook(c))
|
|
285
301
|
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.`);
|
|
@@ -310,6 +326,7 @@ function buildReport(input) {
|
|
|
310
326
|
reasons,
|
|
311
327
|
time: now()
|
|
312
328
|
};
|
|
329
|
+
if (input.compiled) report.compiled = true;
|
|
313
330
|
if (input.selfDuration !== void 0) report.selfDuration = input.selfDuration;
|
|
314
331
|
if (input.treeDuration !== void 0) report.treeDuration = input.treeDuration;
|
|
315
332
|
if (input.commitPriority) report.commitPriority = input.commitPriority;
|
|
@@ -347,10 +364,10 @@ var KIND_LABEL = {
|
|
|
347
364
|
};
|
|
348
365
|
function summarize(report) {
|
|
349
366
|
const counts2 = /* @__PURE__ */ new Map();
|
|
350
|
-
for (const c of [...report.propChanges, ...report.stateChanges, ...report.hookChanges]) {
|
|
367
|
+
for (const c of [...report.propChanges || [], ...report.stateChanges || [], ...report.hookChanges || []]) {
|
|
351
368
|
counts2.set(c.kind, (counts2.get(c.kind) ?? 0) + 1);
|
|
352
369
|
}
|
|
353
|
-
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind]}`);
|
|
370
|
+
const parts = [...counts2].map(([kind, n]) => `${n} ${KIND_LABEL[kind] || kind}`);
|
|
354
371
|
if (parts.length === 0) parts.push("no changes");
|
|
355
372
|
return parts.join(", ");
|
|
356
373
|
}
|
|
@@ -372,16 +389,27 @@ function printReport(report, options) {
|
|
|
372
389
|
}
|
|
373
390
|
|
|
374
391
|
// src/state.ts
|
|
392
|
+
var MAX_SEEN_COMPONENTS = 500;
|
|
375
393
|
var KEY = /* @__PURE__ */ Symbol.for("rerender-lens.state");
|
|
376
394
|
function getState() {
|
|
377
395
|
const g = globalThis;
|
|
378
396
|
let s = g[KEY];
|
|
379
397
|
if (!s) {
|
|
380
|
-
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 };
|
|
398
|
+
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 };
|
|
381
399
|
g[KEY] = s;
|
|
382
400
|
}
|
|
383
401
|
return s;
|
|
384
402
|
}
|
|
403
|
+
function noteRendered(s, name, tracked) {
|
|
404
|
+
if (!s.seen.has(name)) {
|
|
405
|
+
if (s.seen.size >= MAX_SEEN_COMPONENTS) {
|
|
406
|
+
s.seenOverflow = true;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
s.seen.add(name);
|
|
410
|
+
}
|
|
411
|
+
if (tracked) s.seenTracked.add(name);
|
|
412
|
+
}
|
|
385
413
|
function warnOnce(key, message) {
|
|
386
414
|
const s = getState();
|
|
387
415
|
if (s.warnedOnce.has(key)) return;
|
|
@@ -459,6 +487,9 @@ function init(options = {}) {
|
|
|
459
487
|
const s = getState();
|
|
460
488
|
s.options = { ...options };
|
|
461
489
|
s.printed.clear();
|
|
490
|
+
s.seen.clear();
|
|
491
|
+
s.seenTracked.clear();
|
|
492
|
+
s.seenOverflow = false;
|
|
462
493
|
if (!s.enabled) {
|
|
463
494
|
s.detach = attach();
|
|
464
495
|
s.enabled = true;
|
|
@@ -474,6 +505,27 @@ function disable() {
|
|
|
474
505
|
}
|
|
475
506
|
|
|
476
507
|
// src/hookNames.ts
|
|
508
|
+
var NODE_COUNT = {
|
|
509
|
+
useState: 1,
|
|
510
|
+
useReducer: 1,
|
|
511
|
+
useRef: 1,
|
|
512
|
+
useMemo: 1,
|
|
513
|
+
useCallback: 1,
|
|
514
|
+
useEffect: 1,
|
|
515
|
+
useLayoutEffect: 1,
|
|
516
|
+
useInsertionEffect: 1,
|
|
517
|
+
useImperativeHandle: 1,
|
|
518
|
+
useSyncExternalStore: 2,
|
|
519
|
+
useTransition: 2,
|
|
520
|
+
useDeferredValue: 1,
|
|
521
|
+
useId: 1,
|
|
522
|
+
useOptimistic: 1,
|
|
523
|
+
useActionState: 3,
|
|
524
|
+
useFormState: 3,
|
|
525
|
+
useEffectEvent: 1,
|
|
526
|
+
useCacheRefresh: 1
|
|
527
|
+
};
|
|
528
|
+
var BUILTIN_HOOKS = /* @__PURE__ */ new Set([...Object.keys(NODE_COUNT), "useContext", "useDebugValue", "use"]);
|
|
477
529
|
var noop = () => {
|
|
478
530
|
};
|
|
479
531
|
function customHooksFromStack(stack, componentName, marker) {
|
|
@@ -488,7 +540,7 @@ function customHooksFromStack(stack, componentName, marker) {
|
|
|
488
540
|
continue;
|
|
489
541
|
}
|
|
490
542
|
if (name === componentName || name.endsWith("." + componentName)) break;
|
|
491
|
-
if (/^use[A-Z0-9_]/.test(name)) names.push(name);
|
|
543
|
+
if (/^use[A-Z0-9_]/.test(name) && !BUILTIN_HOOKS.has(name)) names.push(name);
|
|
492
544
|
if (names.length > 8) break;
|
|
493
545
|
}
|
|
494
546
|
return names;
|
|
@@ -772,6 +824,11 @@ function isMemoizedFiber(fiber) {
|
|
|
772
824
|
}
|
|
773
825
|
return false;
|
|
774
826
|
}
|
|
827
|
+
function isCompiledFiber(fiber) {
|
|
828
|
+
if (fiber.tag === ClassComponent) return false;
|
|
829
|
+
const cache2 = fiber.updateQueue?.memoCache;
|
|
830
|
+
return !!cache2 && Array.isArray(cache2.data);
|
|
831
|
+
}
|
|
775
832
|
function durationsOf(fiber) {
|
|
776
833
|
if (typeof fiber.actualDuration !== "number") return null;
|
|
777
834
|
const tree = fiber.actualDuration;
|
|
@@ -924,7 +981,8 @@ function diffHooks(fiber, alt) {
|
|
|
924
981
|
const labels = hookLabelsFor(fiber, b);
|
|
925
982
|
let i = 0;
|
|
926
983
|
while (a && b) {
|
|
927
|
-
|
|
984
|
+
const deferred = labels?.[i] === "useDeferredValue";
|
|
985
|
+
if ((deferred || isStateNode(b) || isStoreNode(b)) && !Object.is(a.memoizedState, b.memoizedState)) {
|
|
928
986
|
const hook = labels?.[i] ?? hookLabel2(b);
|
|
929
987
|
out.push({
|
|
930
988
|
path: `${hook}#${i}`,
|
|
@@ -958,6 +1016,10 @@ function diffContexts(fiber, alt) {
|
|
|
958
1016
|
let a = alt.dependencies?.firstContext ?? null;
|
|
959
1017
|
let b = fiber.dependencies?.firstContext ?? null;
|
|
960
1018
|
let i = 0;
|
|
1019
|
+
if (b && !("memoizedValue" in b)) {
|
|
1020
|
+
warnOnce("contextValues", "this React does not record context values on the fiber (React < 18): context changes are reported as plain parent re-renders.");
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
961
1023
|
while (a && b) {
|
|
962
1024
|
if (a.context === b.context && !Object.is(a.memoizedValue, b.memoizedValue)) {
|
|
963
1025
|
const name = b.context.displayName ?? "Context";
|
|
@@ -990,10 +1052,26 @@ function diffContexts(fiber, alt) {
|
|
|
990
1052
|
return out;
|
|
991
1053
|
}
|
|
992
1054
|
var isGenuine2 = (c) => c.kind === "different" || c.kind === "added" || c.kind === "removed";
|
|
1055
|
+
var isRefObject2 = (v) => {
|
|
1056
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1057
|
+
const keys = Object.keys(v);
|
|
1058
|
+
return keys.length === 1 && keys[0] === "current";
|
|
1059
|
+
};
|
|
1060
|
+
function diffProps(prev, next) {
|
|
1061
|
+
if (prev === next) return [];
|
|
1062
|
+
const pr = prev.ref;
|
|
1063
|
+
const nr = next.ref;
|
|
1064
|
+
if (pr !== nr && isRefObject2(pr) && isRefObject2(nr)) {
|
|
1065
|
+
const { ref: _p, ...restPrev } = prev;
|
|
1066
|
+
const { ref: _n, ...restNext } = next;
|
|
1067
|
+
return [{ path: "ref", kind: "deep-equal", prev: pr, next: nr }, ...diffRecords(restPrev, restNext)];
|
|
1068
|
+
}
|
|
1069
|
+
return diffRecords(prev, next);
|
|
1070
|
+
}
|
|
993
1071
|
function analyze(fiber, alt, trackHooks) {
|
|
994
1072
|
const prevProps = alt.memoizedProps ?? {};
|
|
995
1073
|
const nextProps = fiber.memoizedProps ?? {};
|
|
996
|
-
const propChanges =
|
|
1074
|
+
const propChanges = diffProps(prevProps, nextProps);
|
|
997
1075
|
let stateChanges = [];
|
|
998
1076
|
let hookChanges = [];
|
|
999
1077
|
if (fiber.tag === ClassComponent) {
|
|
@@ -1006,7 +1084,7 @@ function analyze(fiber, alt, trackHooks) {
|
|
|
1006
1084
|
} else if (trackHooks) {
|
|
1007
1085
|
hookChanges = [...diffHooks(fiber, alt), ...diffContexts(fiber, alt)];
|
|
1008
1086
|
}
|
|
1009
|
-
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore";
|
|
1087
|
+
const isStateHook = (h) => h.hook !== "useContext" && h.hook !== "useSyncExternalStore" && h.hook !== "useDeferredValue";
|
|
1010
1088
|
const causes = [];
|
|
1011
1089
|
if (propChanges.some(isGenuine2)) causes.push("props");
|
|
1012
1090
|
if (stateChanges.some(isGenuine2) || hookChanges.some((h) => isStateHook(h) && isGenuine2(h))) causes.push("state");
|
|
@@ -1046,6 +1124,24 @@ var lastCommit = null;
|
|
|
1046
1124
|
var EFFECT_LOOP_WINDOW_MS = 50;
|
|
1047
1125
|
var MAX_REPORTS_PER_COMMIT = 200;
|
|
1048
1126
|
var COMMIT_TIME_BUDGET_MS = 25;
|
|
1127
|
+
function isDeferredCatchUp(fiber) {
|
|
1128
|
+
const types = fiber._debugHookTypes;
|
|
1129
|
+
if (!types || !types.includes("useDeferredValue") || !fiber.alternate) return false;
|
|
1130
|
+
const first = fiber.memoizedState;
|
|
1131
|
+
if (!isHookList(first)) return false;
|
|
1132
|
+
const labels = hookLabelsFor(fiber, first);
|
|
1133
|
+
if (!labels) return false;
|
|
1134
|
+
let a = fiber.alternate.memoizedState;
|
|
1135
|
+
let b = first;
|
|
1136
|
+
for (let i = 0; a && b; a = a.next, b = b.next, i++) {
|
|
1137
|
+
if (labels[i] === "useDeferredValue" && !Object.is(a.memoizedState, b.memoizedState)) return true;
|
|
1138
|
+
}
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
function isUnder(fiber, boundaries) {
|
|
1142
|
+
for (let f = fiber.return; f && f.tag !== HostRoot; f = f.return) if (f.tag === SuspenseComponent && boundaries.includes(f)) return true;
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1049
1145
|
function onCommit(root, commitPriority) {
|
|
1050
1146
|
const s = getState();
|
|
1051
1147
|
if (!s.enabled) return;
|
|
@@ -1055,17 +1151,20 @@ function onCommit(root, commitPriority) {
|
|
|
1055
1151
|
const rendered = [];
|
|
1056
1152
|
const renderedNames = /* @__PURE__ */ new Set();
|
|
1057
1153
|
let hot = false;
|
|
1058
|
-
let
|
|
1154
|
+
let resolvedBoundaries = null;
|
|
1059
1155
|
const stack = [root.current];
|
|
1060
1156
|
while (stack.length) {
|
|
1061
1157
|
const fiber = stack.pop();
|
|
1062
1158
|
const alt = fiber.alternate;
|
|
1063
1159
|
if (alt && isComponentTag(fiber.tag) && didRender(fiber)) {
|
|
1064
1160
|
if (isHotSwapped(fiber, alt)) hot = true;
|
|
1065
|
-
|
|
1066
|
-
|
|
1161
|
+
const name = fiberName(fiber);
|
|
1162
|
+
renderedNames.add(name);
|
|
1163
|
+
const tracked = shouldTrack(fiberType(fiber), o);
|
|
1164
|
+
noteRendered(s, name, tracked);
|
|
1165
|
+
if (tracked) rendered.push(fiber);
|
|
1067
1166
|
}
|
|
1068
|
-
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null)
|
|
1167
|
+
if (fiber.tag === SuspenseComponent && alt && alt.memoizedState !== null && fiber.memoizedState === null) (resolvedBoundaries ?? (resolvedBoundaries = [])).push(fiber);
|
|
1069
1168
|
if (!alt || fiber.child !== alt.child) {
|
|
1070
1169
|
for (let c = fiber.child; c; c = c.sibling) stack.push(c);
|
|
1071
1170
|
}
|
|
@@ -1077,10 +1176,10 @@ function onCommit(root, commitPriority) {
|
|
|
1077
1176
|
let commitCause;
|
|
1078
1177
|
let afterCommit;
|
|
1079
1178
|
const inputDriven = commitPriority === "immediate" || commitPriority === "user-blocking";
|
|
1080
|
-
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u))) {
|
|
1179
|
+
if (!inputDriven && lastCommit && at - lastCommit.at < EFFECT_LOOP_WINDOW_MS && updaters.some((u) => lastCommit.rendered.has(u)) && !rendered.some(isDeferredCatchUp)) {
|
|
1081
1180
|
commitCause = "effect-after-commit";
|
|
1082
1181
|
afterCommit = lastCommit.id;
|
|
1083
|
-
} else if (
|
|
1182
|
+
} else if (resolvedBoundaries) commitCause = "suspense-resolved";
|
|
1084
1183
|
if (rendered.length === 0) {
|
|
1085
1184
|
lastCommit = { id: s.nextCommitId, at, rendered: renderedNames };
|
|
1086
1185
|
return;
|
|
@@ -1132,6 +1231,9 @@ function onCommit(root, commitPriority) {
|
|
|
1132
1231
|
owner: ownerName(fiber),
|
|
1133
1232
|
path: componentPath(fiber),
|
|
1134
1233
|
memoized: isMemoizedFiber(fiber),
|
|
1234
|
+
compiled: isCompiledFiber(fiber),
|
|
1235
|
+
classComponent: fiber.tag === ClassComponent,
|
|
1236
|
+
revealed: resolvedBoundaries !== null && a.trigger === "parent" && isUnder(fiber, resolvedBoundaries),
|
|
1135
1237
|
selfDuration: durations ? durations.self : void 0,
|
|
1136
1238
|
treeDuration: durations ? durations.tree : void 0,
|
|
1137
1239
|
commitId,
|
|
@@ -1145,38 +1247,203 @@ function onCommit(root, commitPriority) {
|
|
|
1145
1247
|
}
|
|
1146
1248
|
}
|
|
1147
1249
|
|
|
1250
|
+
// src/causes.ts
|
|
1251
|
+
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);
|
|
1252
|
+
var bump = (m, key, n = 1) => void m.set(key, (m.get(key) || 0) + n);
|
|
1253
|
+
function indexByComponent(reports) {
|
|
1254
|
+
const index = /* @__PURE__ */ new Map();
|
|
1255
|
+
for (const r of reports) {
|
|
1256
|
+
const list = index.get(r.component);
|
|
1257
|
+
if (list) list.push(r);
|
|
1258
|
+
else index.set(r.component, [r]);
|
|
1259
|
+
}
|
|
1260
|
+
return index;
|
|
1261
|
+
}
|
|
1262
|
+
function rootCauseOf(r, commitReports, index = indexByComponent(commitReports)) {
|
|
1263
|
+
let cur = r;
|
|
1264
|
+
const seen = /* @__PURE__ */ new Set([r]);
|
|
1265
|
+
while (cur.trigger === "parent" && cur.parent) {
|
|
1266
|
+
const parent = cur.parent;
|
|
1267
|
+
const candidates = index.get(parent.name);
|
|
1268
|
+
const p = candidates && candidates.find((x) => isAncestorReport(x, cur));
|
|
1269
|
+
if (!p || seen.has(p)) return { name: parent.name, trigger: parent.trigger, report: null };
|
|
1270
|
+
seen.add(p);
|
|
1271
|
+
cur = p;
|
|
1272
|
+
}
|
|
1273
|
+
return cur === r ? null : { name: cur.component, trigger: cur.trigger, report: cur };
|
|
1274
|
+
}
|
|
1275
|
+
function rootCausesOf(reports) {
|
|
1276
|
+
const roots = /* @__PURE__ */ new Map();
|
|
1277
|
+
const rootByReport = /* @__PURE__ */ new Map();
|
|
1278
|
+
const index = indexByComponent(reports);
|
|
1279
|
+
let avoidable = 0;
|
|
1280
|
+
let wasted = 0;
|
|
1281
|
+
for (const r of reports) {
|
|
1282
|
+
if (!r.avoidable) continue;
|
|
1283
|
+
avoidable++;
|
|
1284
|
+
if (typeof r.selfDuration === "number") wasted += r.selfDuration;
|
|
1285
|
+
const root = rootCauseOf(r, reports, index);
|
|
1286
|
+
const name = root ? root.name : r.parent && r.parent.name || "(unknown)";
|
|
1287
|
+
const trigger = root ? root.trigger : r.parent && r.parent.trigger || "parent";
|
|
1288
|
+
rootByReport.set(r, name);
|
|
1289
|
+
let agg = roots.get(name);
|
|
1290
|
+
if (!agg) {
|
|
1291
|
+
agg = { name, trigger, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1292
|
+
roots.set(name, agg);
|
|
1293
|
+
}
|
|
1294
|
+
agg.count++;
|
|
1295
|
+
bump(agg.components, r.component);
|
|
1296
|
+
}
|
|
1297
|
+
return { roots: [...roots.values()].sort((a, b) => b.count - a.count), rootByReport, avoidable, wasted };
|
|
1298
|
+
}
|
|
1299
|
+
function groupByCommit(reports) {
|
|
1300
|
+
const out = /* @__PURE__ */ new Map();
|
|
1301
|
+
for (const r of reports) {
|
|
1302
|
+
if (typeof r.commitId !== "number" || r.commitId <= 0) continue;
|
|
1303
|
+
const list = out.get(r.commitId);
|
|
1304
|
+
if (list) list.push(r);
|
|
1305
|
+
else out.set(r.commitId, [r]);
|
|
1306
|
+
}
|
|
1307
|
+
return out;
|
|
1308
|
+
}
|
|
1309
|
+
function rankRootCauses(reports) {
|
|
1310
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1311
|
+
for (const commit of groupByCommit(reports).values()) {
|
|
1312
|
+
for (const root of rootCausesOf(commit).roots) {
|
|
1313
|
+
let agg = byName.get(root.name);
|
|
1314
|
+
if (!agg) {
|
|
1315
|
+
agg = { name: root.name, trigger: root.trigger, commits: 0, count: 0, components: /* @__PURE__ */ new Map() };
|
|
1316
|
+
byName.set(root.name, agg);
|
|
1317
|
+
}
|
|
1318
|
+
agg.commits++;
|
|
1319
|
+
agg.count += root.count;
|
|
1320
|
+
for (const [c, n] of root.components) bump(agg.components, c, n);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return [...byName.values()].sort((a, b) => b.count - a.count || b.commits - a.commits || a.name.localeCompare(b.name));
|
|
1324
|
+
}
|
|
1325
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1326
|
+
function formatRootCauses(reports, limit = 5) {
|
|
1327
|
+
const ranked = rankRootCauses(reports);
|
|
1328
|
+
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(", ")})`);
|
|
1329
|
+
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1330
|
+
return lines.join("\n");
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1148
1333
|
// src/fixes.ts
|
|
1149
|
-
var
|
|
1334
|
+
var MAX_FIX_REPORTS = 50;
|
|
1150
1335
|
var rootOf = (path) => path.split(/[.[]/)[0] || path;
|
|
1336
|
+
var identifier = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : "value";
|
|
1337
|
+
function shortValue(v, max = 60) {
|
|
1338
|
+
let s;
|
|
1339
|
+
try {
|
|
1340
|
+
s = JSON.stringify(v);
|
|
1341
|
+
} catch {
|
|
1342
|
+
s = String(v);
|
|
1343
|
+
}
|
|
1344
|
+
if (s === void 0) s = String(v);
|
|
1345
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
1346
|
+
}
|
|
1151
1347
|
function fixesFor(r) {
|
|
1152
1348
|
const out = [];
|
|
1153
|
-
const
|
|
1154
|
-
const
|
|
1349
|
+
const ownerName2 = r.owner || r.parent && r.parent.name || null;
|
|
1350
|
+
const propChanges = r.propChanges || [];
|
|
1351
|
+
const changes = [...propChanges, ...r.stateChanges || [], ...r.hookChanges || []];
|
|
1155
1352
|
if (r.avoidable && (changes.length === 0 || r.memoized === false)) {
|
|
1353
|
+
const identical = changes.length === 0;
|
|
1156
1354
|
out.push({
|
|
1157
1355
|
kind: "memo",
|
|
1158
1356
|
owner: r.component,
|
|
1159
1357
|
target: r.component,
|
|
1160
1358
|
prop: null,
|
|
1161
1359
|
label: `Wrap <${r.component}> in React.memo`,
|
|
1162
|
-
detail:
|
|
1360
|
+
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.`,
|
|
1361
|
+
snippet: `// ${r.component}
|
|
1362
|
+
import { memo } from 'react';
|
|
1363
|
+
|
|
1364
|
+
export const ${r.component} = memo(function ${r.component}(props) {
|
|
1365
|
+
// ...
|
|
1366
|
+
});
|
|
1367
|
+
// class components: extend PureComponent instead`
|
|
1163
1368
|
});
|
|
1164
1369
|
}
|
|
1165
|
-
for (const c of
|
|
1166
|
-
if (!
|
|
1370
|
+
for (const c of propChanges) {
|
|
1371
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1372
|
+
const owner = ownerName2 || "?";
|
|
1167
1373
|
const root = rootOf(c.path);
|
|
1374
|
+
const id = identifier(root);
|
|
1168
1375
|
if (root === "children" && (c.kind === "element" || c.kind === "deep-equal")) {
|
|
1169
|
-
out.push({
|
|
1376
|
+
out.push({
|
|
1377
|
+
kind: "children",
|
|
1378
|
+
owner,
|
|
1379
|
+
target: r.component,
|
|
1380
|
+
prop: "children",
|
|
1381
|
+
label: `memoize children of <${r.component}> in <${owner}>`,
|
|
1382
|
+
detail: `<${owner}> re-creates the children of <${r.component}> on every render; they have the same types and props each time.`,
|
|
1383
|
+
snippet: `// ${owner}
|
|
1384
|
+
import { useMemo } from 'react';
|
|
1385
|
+
|
|
1386
|
+
const children = useMemo(() => (
|
|
1387
|
+
<>{/* the same elements */}</>
|
|
1388
|
+
), [/* deps */]);
|
|
1389
|
+
|
|
1390
|
+
<${r.component}>{children}</${r.component}>
|
|
1391
|
+
|
|
1392
|
+
// static children: hoist them to module scope
|
|
1393
|
+
const STATIC = <em>hi</em>;`
|
|
1394
|
+
});
|
|
1170
1395
|
} else if (c.kind === "function") {
|
|
1171
|
-
out.push({
|
|
1396
|
+
out.push({
|
|
1397
|
+
kind: "useCallback",
|
|
1398
|
+
owner,
|
|
1399
|
+
target: r.component,
|
|
1400
|
+
prop: root,
|
|
1401
|
+
label: `useCallback(${root}) in <${owner}>`,
|
|
1402
|
+
detail: `prop "${c.path}" of <${r.component}> is a new function on every render of <${owner}>.`,
|
|
1403
|
+
snippet: `// ${owner}
|
|
1404
|
+
import { useCallback } from 'react';
|
|
1405
|
+
|
|
1406
|
+
const ${id} = useCallback((/* args */) => {
|
|
1407
|
+
// ...
|
|
1408
|
+
}, [/* deps */]);
|
|
1409
|
+
|
|
1410
|
+
<${r.component} ${root}={${id}} />`
|
|
1411
|
+
});
|
|
1172
1412
|
} else if (c.kind === "element") {
|
|
1173
|
-
out.push({
|
|
1413
|
+
out.push({
|
|
1414
|
+
kind: "useMemoElement",
|
|
1415
|
+
owner,
|
|
1416
|
+
target: r.component,
|
|
1417
|
+
prop: root,
|
|
1418
|
+
label: `memoize element prop ${root} in <${owner}>`,
|
|
1419
|
+
detail: `prop "${c.path}" of <${r.component}> is a new element with the same type and props on every render of <${owner}>.`,
|
|
1420
|
+
snippet: `// ${owner}
|
|
1421
|
+
import { useMemo } from 'react';
|
|
1422
|
+
|
|
1423
|
+
const ${id} = useMemo(() => ${shortValue(c.next, 40)}, [/* deps */]);
|
|
1424
|
+
// or pass it as children from a component that does not re-render`
|
|
1425
|
+
});
|
|
1174
1426
|
} else {
|
|
1175
|
-
|
|
1427
|
+
const isArray = Array.isArray(c.next);
|
|
1428
|
+
out.push({
|
|
1429
|
+
kind: "useMemo",
|
|
1430
|
+
owner,
|
|
1431
|
+
target: r.component,
|
|
1432
|
+
prop: root,
|
|
1433
|
+
label: `useMemo(${root}) in <${owner}>`,
|
|
1434
|
+
detail: `prop "${c.path}" of <${r.component}> is a new ${isArray ? "array" : "object"} with the same contents on every render of <${owner}>.`,
|
|
1435
|
+
snippet: `// ${owner}
|
|
1436
|
+
import { useMemo } from 'react';
|
|
1437
|
+
|
|
1438
|
+
const ${id} = useMemo(() => (${shortValue(c.next, 80)}), [/* deps */]);
|
|
1439
|
+
|
|
1440
|
+
// or, when it never changes, hoist it to module scope:
|
|
1441
|
+
const ${id.toUpperCase()} = ${shortValue(c.next, 80)};`
|
|
1442
|
+
});
|
|
1176
1443
|
}
|
|
1177
1444
|
}
|
|
1178
|
-
for (const c of [...r.stateChanges, ...r.hookChanges]) {
|
|
1179
|
-
const isContext = c.hook === "useContext";
|
|
1445
|
+
for (const c of [...r.stateChanges || [], ...r.hookChanges || []]) {
|
|
1446
|
+
const isContext = c.hook === "useContext" || /^useContext/.test(c.path);
|
|
1180
1447
|
const ctxName = isContext ? (/useContext\((.*)\)/.exec(c.path) || [])[1] || "Context" : "";
|
|
1181
1448
|
const providerOwner = isContext && c.provider && c.provider.component ? c.provider.component : null;
|
|
1182
1449
|
if (isContext && c.kind === "different" && c.changedKeys && typeof c.totalKeys === "number" && c.changedKeys.length > 0 && c.changedKeys.length < c.totalKeys) {
|
|
@@ -1186,20 +1453,63 @@ function fixesFor(r) {
|
|
|
1186
1453
|
target: r.component,
|
|
1187
1454
|
prop: ctxName,
|
|
1188
1455
|
label: `split ${ctxName}${providerOwner ? ` in <${providerOwner}>` : ""}: only ${c.changedKeys.join(", ")} changed`,
|
|
1189
|
-
detail: `${c.changedKeys.
|
|
1456
|
+
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.`,
|
|
1457
|
+
snippet: `// ${providerOwner || "Provider"}
|
|
1458
|
+
// one context per independently-changing slice
|
|
1459
|
+
const ${identifier(ctxName)}Static = createContext(...);
|
|
1460
|
+
const ${identifier(ctxName)}${c.changedKeys.map((k) => k[0].toUpperCase() + k.slice(1)).join("")} = createContext(...);
|
|
1461
|
+
|
|
1462
|
+
// or keep one context and let consumers select a slice:
|
|
1463
|
+
const ${c.changedKeys[0]} = useContextSelector(${ctxName}, (v) => v.${c.changedKeys[0]});`
|
|
1190
1464
|
});
|
|
1191
1465
|
continue;
|
|
1192
1466
|
}
|
|
1193
|
-
if (!
|
|
1467
|
+
if (!AVOIDABLE_KINDS.has(c.kind)) continue;
|
|
1194
1468
|
if (isContext) {
|
|
1195
|
-
out.push({
|
|
1469
|
+
out.push({
|
|
1470
|
+
kind: "contextValue",
|
|
1471
|
+
owner: providerOwner || `${ctxName}.Provider`,
|
|
1472
|
+
target: r.component,
|
|
1473
|
+
prop: ctxName,
|
|
1474
|
+
label: `memoize the ${ctxName} provider value${providerOwner ? ` in <${providerOwner}>` : ""}`,
|
|
1475
|
+
detail: `<${r.component}> re-rendered because ${ctxName} produced a new value that is deep-equal to the previous one${providerOwner ? ` (Provider rendered by <${providerOwner}>)` : ""}.`,
|
|
1476
|
+
snippet: `// ${providerOwner || `where <${ctxName}.Provider> is rendered`}
|
|
1477
|
+
const value = useMemo(() => ({ /* ... */ }), [/* deps */]);
|
|
1478
|
+
<${ctxName}.Provider value={value}>`
|
|
1479
|
+
});
|
|
1196
1480
|
} else if (c.hook === "useSyncExternalStore") {
|
|
1197
1481
|
const chain = c.custom || [];
|
|
1198
1482
|
const redux = chain.some((n) => /^use(App)?Selector$/.test(n));
|
|
1199
1483
|
const zustand = !redux && chain.some((n) => /^use[A-Z]\w*Store$/.test(n) || n === "useStore" || n === "useBoundStore");
|
|
1200
|
-
|
|
1484
|
+
const via = chain.length ? ` via ${chain.join(" \u203A ")}` : "";
|
|
1485
|
+
out.push({
|
|
1486
|
+
kind: "storeSnapshot",
|
|
1487
|
+
owner: r.component,
|
|
1488
|
+
target: r.component,
|
|
1489
|
+
prop: c.path,
|
|
1490
|
+
label: redux ? `memoize the selector in <${r.component}>` : zustand ? `useShallow in <${r.component}>` : `stable getSnapshot in <${r.component}>`,
|
|
1491
|
+
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.`,
|
|
1492
|
+
snippet: redux ? `// ${r.component}
|
|
1493
|
+
import { shallowEqual } from 'react-redux';
|
|
1494
|
+
const slice = useSelector(selectSlice, shallowEqual);
|
|
1495
|
+
// or memoize: const selectSlice = createSelector([selectA, selectB], (a, b) => ({ a, b }));` : zustand ? `// ${r.component}
|
|
1496
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
1497
|
+
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
1498
|
+
// or select a primitive: const a = useStore((s) => s.a);` : `// ${r.component}
|
|
1499
|
+
// getSnapshot must return the same reference while the data is unchanged
|
|
1500
|
+
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot /* cached */);`
|
|
1501
|
+
});
|
|
1201
1502
|
} else {
|
|
1202
|
-
out.push({
|
|
1503
|
+
out.push({
|
|
1504
|
+
kind: "bailout",
|
|
1505
|
+
owner: r.component,
|
|
1506
|
+
target: r.component,
|
|
1507
|
+
prop: c.path,
|
|
1508
|
+
label: `bail out before setting ${c.path} in <${r.component}>`,
|
|
1509
|
+
detail: `${c.path} was set to a value deep-equal to the current one (new reference).`,
|
|
1510
|
+
snippet: `// ${r.component}
|
|
1511
|
+
setState((prev) => (deepEqual(prev, next) ? prev : next));`
|
|
1512
|
+
});
|
|
1203
1513
|
}
|
|
1204
1514
|
}
|
|
1205
1515
|
return out;
|
|
@@ -1213,11 +1523,12 @@ function rankFixes(reports) {
|
|
|
1213
1523
|
const key = fixKey(f);
|
|
1214
1524
|
let agg = byKey.get(key);
|
|
1215
1525
|
if (!agg) {
|
|
1216
|
-
agg = { ...f, key, count: 0, components: {} };
|
|
1526
|
+
agg = { ...f, key, count: 0, components: {}, reports: [] };
|
|
1217
1527
|
byKey.set(key, agg);
|
|
1218
1528
|
}
|
|
1219
1529
|
agg.count++;
|
|
1220
1530
|
agg.components[r.component] = (agg.components[r.component] || 0) + 1;
|
|
1531
|
+
if (agg.reports.length < MAX_FIX_REPORTS) agg.reports.push(r);
|
|
1221
1532
|
}
|
|
1222
1533
|
}
|
|
1223
1534
|
return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
@@ -1227,6 +1538,8 @@ function formatFixes(reports, limit = 10) {
|
|
|
1227
1538
|
if (!ranked.length) return "No avoidable re-renders.";
|
|
1228
1539
|
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(", ")})`);
|
|
1229
1540
|
if (ranked.length > limit) lines.push(` \u2026 ${ranked.length - limit} more`);
|
|
1541
|
+
const roots = formatRootCauses(reports, 5);
|
|
1542
|
+
if (roots) lines.push("", "Root causes:", roots);
|
|
1230
1543
|
return lines.join("\n");
|
|
1231
1544
|
}
|
|
1232
1545
|
|
|
@@ -1325,7 +1638,7 @@ ${formatFixes(reports)}`);
|
|
|
1325
1638
|
};
|
|
1326
1639
|
}
|
|
1327
1640
|
|
|
1328
|
-
// src/
|
|
1641
|
+
// src/runner-report.ts
|
|
1329
1642
|
var OUT_ENV = "RERENDER_LENS_OUT";
|
|
1330
1643
|
function setupRerenderLens(options = {}, hooks = {}) {
|
|
1331
1644
|
ensureDevtoolsHook();
|
|
@@ -1360,49 +1673,55 @@ function slim(r) {
|
|
|
1360
1673
|
state: void 0
|
|
1361
1674
|
};
|
|
1362
1675
|
}
|
|
1363
|
-
|
|
1676
|
+
function createRunFile() {
|
|
1677
|
+
const dir = path.join(os.tmpdir(), "rerender-lens");
|
|
1678
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1679
|
+
const file = path.join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
|
|
1680
|
+
fs.writeFileSync(file, "");
|
|
1681
|
+
process.env[OUT_ENV] = file;
|
|
1682
|
+
return file;
|
|
1683
|
+
}
|
|
1684
|
+
function finishRun(file, options = {}) {
|
|
1685
|
+
const reports = readRun(file);
|
|
1686
|
+
const log = options.write ?? ((line) => void process.stdout.write(line + "\n"));
|
|
1687
|
+
const avoidable = reports.filter((r) => r.avoidable).length;
|
|
1688
|
+
let error = null;
|
|
1689
|
+
log("");
|
|
1690
|
+
log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
|
|
1691
|
+
if (avoidable) log(formatFixes(reports, options.limit ?? 10));
|
|
1692
|
+
if (options.exportTo) fs.writeFileSync(options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
|
|
1693
|
+
if (options.budget) {
|
|
1694
|
+
const budget = JSON.parse(fs.readFileSync(options.budget, "utf8"));
|
|
1695
|
+
const result = checkBudget(reports, budget);
|
|
1696
|
+
if (!result.ok) {
|
|
1697
|
+
const lines = result.violations.map((v) => ` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
|
|
1698
|
+
for (const line of lines) log(line);
|
|
1699
|
+
error = new Error(`rerender-lens: re-render budget exceeded (${options.budget})
|
|
1700
|
+
${lines.join("\n")}`);
|
|
1701
|
+
} else log(` within budget (${options.budget})`);
|
|
1702
|
+
}
|
|
1703
|
+
try {
|
|
1704
|
+
fs.rmSync(file, { force: true });
|
|
1705
|
+
} catch {
|
|
1706
|
+
}
|
|
1707
|
+
if (process.env[OUT_ENV] === file) delete process.env[OUT_ENV];
|
|
1708
|
+
return { reports, error };
|
|
1709
|
+
}
|
|
1710
|
+
var RunReporterCore = class {
|
|
1364
1711
|
constructor(options = {}) {
|
|
1365
1712
|
this.done = false;
|
|
1366
1713
|
this.options = options;
|
|
1367
|
-
|
|
1368
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1369
|
-
this.file = path.join(dir, `run-${process.pid}-${Date.now()}.jsonl`);
|
|
1370
|
-
fs.writeFileSync(this.file, "");
|
|
1371
|
-
process.env[OUT_ENV] = this.file;
|
|
1714
|
+
this.file = createRunFile();
|
|
1372
1715
|
}
|
|
1373
|
-
|
|
1716
|
+
/** Publish the path again (the runner may have replaced `process.env` since the constructor ran). */
|
|
1717
|
+
arm() {
|
|
1374
1718
|
process.env[OUT_ENV] = this.file;
|
|
1375
1719
|
}
|
|
1376
|
-
|
|
1377
|
-
this.finish();
|
|
1378
|
-
}
|
|
1379
|
-
/** Vitest < 3 name for the same event. */
|
|
1380
|
-
onFinished() {
|
|
1381
|
-
this.finish();
|
|
1382
|
-
}
|
|
1720
|
+
/** The run's result, or null when the run was already finished (runners fire two "done" events). */
|
|
1383
1721
|
finish() {
|
|
1384
|
-
if (this.done) return;
|
|
1722
|
+
if (this.done) return null;
|
|
1385
1723
|
this.done = true;
|
|
1386
|
-
|
|
1387
|
-
const log = this.options.write ?? ((line) => void process.stdout.write(line + "\n"));
|
|
1388
|
-
const avoidable = reports.filter((r) => r.avoidable).length;
|
|
1389
|
-
log("");
|
|
1390
|
-
log(`rerender-lens: ${reports.length} report${reports.length === 1 ? "" : "s"}, ${avoidable} avoidable across the run`);
|
|
1391
|
-
if (avoidable) log(formatFixes(reports, this.options.limit ?? 10));
|
|
1392
|
-
if (this.options.exportTo) fs.writeFileSync(this.options.exportTo, JSON.stringify({ rerenderLens: true, version: 2, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), reports }, null, 2));
|
|
1393
|
-
if (this.options.budget) {
|
|
1394
|
-
const budget = JSON.parse(fs.readFileSync(this.options.budget, "utf8"));
|
|
1395
|
-
const result = checkBudget(reports, budget);
|
|
1396
|
-
if (!result.ok) {
|
|
1397
|
-
for (const v of result.violations) log(` budget exceeded: <${v.component}> ${v.avoidable} avoidable (allowed ${v.allowed})`);
|
|
1398
|
-
process.exitCode = 1;
|
|
1399
|
-
} else log(` within budget (${this.options.budget})`);
|
|
1400
|
-
}
|
|
1401
|
-
try {
|
|
1402
|
-
fs.rmSync(this.file, { force: true });
|
|
1403
|
-
} catch {
|
|
1404
|
-
}
|
|
1405
|
-
if (process.env[OUT_ENV] === this.file) delete process.env[OUT_ENV];
|
|
1724
|
+
return finishRun(this.file, this.options);
|
|
1406
1725
|
}
|
|
1407
1726
|
};
|
|
1408
1727
|
function readRun(file) {
|
|
@@ -1419,6 +1738,27 @@ function readRun(file) {
|
|
|
1419
1738
|
return out;
|
|
1420
1739
|
}
|
|
1421
1740
|
|
|
1741
|
+
// src/vitest.ts
|
|
1742
|
+
var RerenderLensReporter = class {
|
|
1743
|
+
constructor(options = {}) {
|
|
1744
|
+
this.core = new RunReporterCore(options);
|
|
1745
|
+
}
|
|
1746
|
+
onInit() {
|
|
1747
|
+
this.core.arm();
|
|
1748
|
+
}
|
|
1749
|
+
onTestRunEnd() {
|
|
1750
|
+
this.finish();
|
|
1751
|
+
}
|
|
1752
|
+
/** Vitest < 3 name for the same event. */
|
|
1753
|
+
onFinished() {
|
|
1754
|
+
this.finish();
|
|
1755
|
+
}
|
|
1756
|
+
finish() {
|
|
1757
|
+
const result = this.core.finish();
|
|
1758
|
+
if (result?.error) process.exitCode = 1;
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1422
1762
|
exports.OUT_ENV = OUT_ENV;
|
|
1423
1763
|
exports.checkBudget = checkBudget;
|
|
1424
1764
|
exports.default = RerenderLensReporter;
|