next-leak 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -267,7 +267,7 @@ separates them, because each one has a different fix:
267
267
  with the traffic, so the longer run decides the same thing. If the heap is
268
268
  flat but RSS keeps climbing, the report says so explicitly: that is an
269
269
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
270
- - **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
270
+ - **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. The culprit is whoever holds the most of the growth, each byte counted once even when a container and what it holds are both reported; when more of it is unattributed than any owner holds, no culprit is named rather than a smaller one. An `ISSUE-<route>.md` draft is generated **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
271
271
  - **`saturating`** — every cycle grew, but by less than the one before, and the
272
272
  last by less than half the first. That is the shape of a bounded store
273
273
  running out of new keys, not of memory going missing. It matters because the
@@ -32,7 +32,13 @@ export type AttributedDiff = {
32
32
  };
33
33
  /**
34
34
  * Attributes every finding and derives the route-level verdict: the
35
- * owner+source group holding the most attributed retained bytes wins;
36
- * with nothing attributed the route stays `unattributed`.
35
+ * owner+source group holding the most exclusive retained bytes wins.
36
+ *
37
+ * What nothing could attribute competes as a group of its own. When it is the
38
+ * largest, the route stays `unattributed`: naming a smaller store as the
39
+ * culprit misleads, and an `app` or `dependency` culprit is what tells the
40
+ * issue draft not to file upstream. On the vercel/next.js#99335 long run the
41
+ * unnamed 19.84 MB map lost to a 9.31 MB fsChecker LRU reported at a
42
+ * dominance of 1, because unattributed bytes were left out of the share.
37
43
  */
38
44
  export declare function attributeDiff(diff: HeapDiff, registry: ModuleRegistry): AttributedDiff;
@@ -92646,6 +92646,12 @@ var FRAMEWORK_CHAIN_MARKERS = [
92646
92646
  ["NextNodeServer", "Next server"],
92647
92647
  ["AppPageRouteModule", "App Router page module"],
92648
92648
  ["AppRouteRouteModule", "App Router route handler module"],
92649
+ // Ahead of the incremental cache, which holds it: a chain reaching through
92650
+ // both should name the store that grows. On the vercel/next.js#99335
92651
+ // reproduction its unbounded Map was the largest finding (19.84 MB over
92652
+ // 240,000 requests) and, unnamed, lost the route to the bounded fsChecker
92653
+ // LRU at 9.31 MB with a reported dominance of 1.
92654
+ ["SharedCacheControls", "shared cache controls"],
92649
92655
  ["incrementalCache", "incremental cache"],
92650
92656
  ["IncrementalCache", "incremental cache"],
92651
92657
  ["ReactServer", "React Server Components renderer"],
@@ -92677,17 +92683,60 @@ function attributeFinding(finding, registry) {
92677
92683
  }
92678
92684
  return best;
92679
92685
  }
92686
+ function enclosingIndexes(all) {
92687
+ const indexById = /* @__PURE__ */ new Map();
92688
+ for (const [index, finding] of all.entries()) {
92689
+ if (finding.nodeId !== void 0) {
92690
+ indexById.set(finding.nodeId, index);
92691
+ }
92692
+ }
92693
+ return all.map(
92694
+ (finding) => finding.containedIn === void 0 ? void 0 : indexById.get(finding.containedIn)
92695
+ );
92696
+ }
92697
+ function inheritOwners(own, enclosing) {
92698
+ return own.map((attribution, index) => {
92699
+ let current = enclosing[index];
92700
+ for (let hops = 0; attribution.owner === "unattributed" && hops < own.length; hops += 1) {
92701
+ if (current === void 0) {
92702
+ break;
92703
+ }
92704
+ const ancestor = own[current];
92705
+ if (ancestor !== void 0 && ancestor.owner !== "unattributed") {
92706
+ return ancestor;
92707
+ }
92708
+ current = enclosing[current];
92709
+ }
92710
+ return attribution;
92711
+ });
92712
+ }
92713
+ function exclusiveBytes(all, enclosing) {
92714
+ const bytes = all.map((finding) => finding.retainedBytes);
92715
+ for (const [index, parent] of enclosing.entries()) {
92716
+ if (parent !== void 0) {
92717
+ bytes[parent] = (bytes[parent] ?? 0) - (all[index]?.retainedBytes ?? 0);
92718
+ }
92719
+ }
92720
+ return bytes.map((value) => Math.max(0, value));
92721
+ }
92680
92722
  function attributeDiff(diff, registry) {
92681
92723
  const all = [...diff.grownNodes, ...diff.newNodes];
92682
- const findings = all.map((finding) => attributeFinding(finding, registry));
92724
+ const enclosing = enclosingIndexes(all);
92725
+ const findings = inheritOwners(
92726
+ all.map((finding) => attributeFinding(finding, registry)),
92727
+ enclosing
92728
+ );
92729
+ const exclusive = exclusiveBytes(all, enclosing);
92683
92730
  const byGroup = /* @__PURE__ */ new Map();
92684
- let attributedBytes = 0;
92731
+ let unattributedBytes = 0;
92732
+ let totalBytes = 0;
92685
92733
  for (const [index, attribution] of findings.entries()) {
92734
+ const bytes = exclusive[index] ?? 0;
92735
+ totalBytes += bytes;
92686
92736
  if (attribution.owner === "unattributed") {
92737
+ unattributedBytes += bytes;
92687
92738
  continue;
92688
92739
  }
92689
- const bytes = all[index]?.retainedBytes ?? 0;
92690
- attributedBytes += bytes;
92691
92740
  const key = `${attribution.owner}|${attribution.source ?? ""}|${attribution.packageName ?? ""}`;
92692
92741
  const group = byGroup.get(key) ?? { attribution, bytes: 0 };
92693
92742
  group.bytes += bytes;
@@ -92699,7 +92748,7 @@ function attributeDiff(diff, registry) {
92699
92748
  winner = group;
92700
92749
  }
92701
92750
  }
92702
- const route = winner === null || attributedBytes === 0 ? { ...UNATTRIBUTED, dominance: 0 } : { ...winner.attribution, dominance: winner.bytes / attributedBytes };
92751
+ const route = winner === null || winner.bytes === 0 || unattributedBytes > winner.bytes ? { ...UNATTRIBUTED, dominance: 0 } : { ...winner.attribution, dominance: winner.bytes / totalBytes };
92703
92752
  return { findings, route };
92704
92753
  }
92705
92754
 
@@ -92805,12 +92854,16 @@ function diffAgainstBaseline(baseline, after, options = {}) {
92805
92854
  if (node.retainedSize >= resolved.newThresholdBytes) {
92806
92855
  const chain = walkChain(node, resolved.chainDepth);
92807
92856
  fresh.push({
92808
- kind: "new",
92809
- nodeType: node.type,
92810
- name: truncateLabel(node.name),
92811
- retainedBytes: node.retainedSize,
92812
- retainerChain: chain.parts.join(" <- "),
92813
- moduleIds: collectModuleIds(chain.nodes)
92857
+ node,
92858
+ finding: {
92859
+ kind: "new",
92860
+ nodeType: node.type,
92861
+ name: truncateLabel(node.name),
92862
+ retainedBytes: node.retainedSize,
92863
+ retainerChain: chain.parts.join(" <- "),
92864
+ moduleIds: collectModuleIds(chain.nodes),
92865
+ nodeId: node.id
92866
+ }
92814
92867
  });
92815
92868
  }
92816
92869
  return;
@@ -92819,12 +92872,16 @@ function diffAgainstBaseline(baseline, after, options = {}) {
92819
92872
  if (before !== void 0 && node.retainedSize - before >= resolved.grownThresholdBytes) {
92820
92873
  const chain = walkChain(node, resolved.chainDepth);
92821
92874
  grown.push({
92822
- kind: "grown",
92823
- nodeType: node.type,
92824
- name: truncateLabel(node.name),
92825
- retainedBytes: node.retainedSize - before,
92826
- retainerChain: chain.parts.join(" <- "),
92827
- moduleIds: collectModuleIds(chain.nodes)
92875
+ node,
92876
+ finding: {
92877
+ kind: "grown",
92878
+ nodeType: node.type,
92879
+ name: truncateLabel(node.name),
92880
+ retainedBytes: node.retainedSize - before,
92881
+ retainerChain: chain.parts.join(" <- "),
92882
+ moduleIds: collectModuleIds(chain.nodes),
92883
+ nodeId: node.id
92884
+ }
92828
92885
  });
92829
92886
  }
92830
92887
  });
@@ -92837,14 +92894,36 @@ function diffAgainstBaseline(baseline, after, options = {}) {
92837
92894
  }
92838
92895
  }
92839
92896
  typeDeltas.sort((a, b) => b.deltaBytes - a.deltaBytes);
92840
- grown.sort((a, b) => b.retainedBytes - a.retainedBytes);
92841
- fresh.sort((a, b) => b.retainedBytes - a.retainedBytes);
92897
+ const bySize = (a, b) => b.finding.retainedBytes - a.finding.retainedBytes;
92898
+ const keptGrown = grown.sort(bySize).slice(0, resolved.maxFindings);
92899
+ const keptFresh = fresh.sort(bySize).slice(0, resolved.maxFindings);
92900
+ linkContainment([...keptGrown, ...keptFresh]);
92842
92901
  return {
92843
92902
  typeDeltas,
92844
- grownNodes: grown.slice(0, resolved.maxFindings),
92845
- newNodes: fresh.slice(0, resolved.maxFindings)
92903
+ grownNodes: keptGrown.map((candidate) => candidate.finding),
92904
+ newNodes: keptFresh.map((candidate) => candidate.finding)
92846
92905
  };
92847
92906
  }
92907
+ var MAX_DOMINATOR_DEPTH = 1e4;
92908
+ function linkContainment(kept) {
92909
+ const keptIds = new Set(kept.map((candidate) => candidate.node.id));
92910
+ for (const candidate of kept) {
92911
+ let current = candidate.node.dominatorNode;
92912
+ for (let depth = 0; depth < MAX_DOMINATOR_DEPTH && current; depth += 1) {
92913
+ if (current.id === candidate.node.id) {
92914
+ break;
92915
+ }
92916
+ if (keptIds.has(current.id)) {
92917
+ candidate.finding.containedIn = current.id;
92918
+ break;
92919
+ }
92920
+ if (current.dominatorNode?.id === current.id) {
92921
+ break;
92922
+ }
92923
+ current = current.dominatorNode;
92924
+ }
92925
+ }
92926
+ }
92848
92927
  var SnapshotError = class extends Error {
92849
92928
  /**
92850
92929
  * Bytes that had to fit in one string, when that is why the snapshot was
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  unregisterChild,
24
24
  validateTarget,
25
25
  writtenConfigNote
26
- } from "./chunk-GBRBWUZZ.js";
26
+ } from "./chunk-5OI4MUND.js";
27
27
  import "./chunk-IKWF7SSB.js";
28
28
  import {
29
29
  classifyTrend
@@ -18,6 +18,8 @@ export type HeapNodeLike = {
18
18
  references?: Array<{
19
19
  name_or_index: string | number;
20
20
  }>;
21
+ /** Immediate dominator, as memlab computes it; absent in most fixtures. */
22
+ dominatorNode?: HeapNodeLike | null;
21
23
  };
22
24
  export type HeapLike = {
23
25
  nodes: {
@@ -53,6 +55,15 @@ export type NodeFinding = {
53
55
  retainerChain: string;
54
56
  /** Bundler module ids seen along the chain (needs `resolveNumeric`). */
55
57
  moduleIds: number[];
58
+ /** Snapshot node id. Absent in run.json files written before it existed. */
59
+ nodeId?: number;
60
+ /**
61
+ * `nodeId` of the nearest other finding that dominates this one, when there
62
+ * is one. A grown container's delta already includes everything it
63
+ * dominates, so without this the same bytes are counted once per level —
64
+ * see `attributeDiff`.
65
+ */
66
+ containedIn?: number;
56
67
  };
57
68
  export type HeapDiff = {
58
69
  typeDeltas: TypeDelta[];
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  sourceIndexAt,
42
42
  summarizeBaseline,
43
43
  validateTarget
44
- } from "./chunk-GBRBWUZZ.js";
44
+ } from "./chunk-5OI4MUND.js";
45
45
  import "./chunk-IKWF7SSB.js";
46
46
  import {
47
47
  renderHtmlReport
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",