next-leak 0.13.0 → 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
@@ -193,7 +193,10 @@ entry, so the route reads as flat whatever it retains, and that false negative
193
193
  lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
194
194
  and ISR all key on the params). If your app answers 404 for params it never
195
195
  prerendered, the run says so through its non-2xx count; drop the marker then.
196
- When a run skips a route it prints the same fragment.
196
+ When the build prerendered nothing for a route, the value is `REPLACE-ME-{n}`:
197
+ put the shape of a real value in place of `REPLACE-ME` and keep the `-{n}`, or
198
+ the run measures a single cache entry. When a run skips a route it prints the
199
+ same fragment.
197
200
 
198
201
  ```json
199
202
  ```
@@ -240,7 +243,7 @@ separates them, because each one has a different fix:
240
243
  | One-time warm-up growth (JIT, lazy caches) | `stable` | The first cycle is excluded from the verdict; warm-up flattens, leaks keep climbing |
241
244
  | A route that is expensive, not leaky | `failed` under load it cannot sustain, flat once concurrency fits | Real leaks survive forced GC at any concurrency; saturation disappears when load drops |
242
245
  | Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
243
- | A cache filling up under the load that measures it | `saturating` | A bounded store grows by less each cycle as new keys get rarer; a leak does not decelerate |
246
+ | A cache filling up under the load that measures it | `saturating` | A bounded store grows by less each cycle as new keys get rarer; a leak does not decelerate. A bend that lands on a steady rate above the gate is a leak under the fill, and stays `leak` |
244
247
  | Memory a forced GC reclaims that production never reclaims in time | `pressure` | Every verdict sample is post-GC; the peaks are sampled under load, and a ceiling every settled cycle comes back to is a regime, not an episode |
245
248
  | Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
246
249
  | A leak in your code vs a dependency vs Next itself | `culprit: src/app/x/page.tsx (your code)` — or the package, or framework internals | Retainer chains mapped through the build's source maps |
@@ -264,14 +267,22 @@ separates them, because each one has a different fix:
264
267
  with the traffic, so the longer run decides the same thing. If the heap is
265
268
  flat but RSS keeps climbing, the report says so explicitly: that is an
266
269
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
267
- - **`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.
268
271
  - **`saturating`** — every cycle grew, but by less than the one before, and the
269
272
  last by less than half the first. That is the shape of a bounded store
270
273
  running out of new keys, not of memory going missing. It matters because the
271
274
  alternative was calling it a leak: a `use cache` route measured on Next
272
275
  16.3.3 came out at +603 MB per 1000 requests that was entirely the cache
273
276
  storing what it had been asked to store — the same route dropped to +88 MB
274
- once the payload was removed. Because the shape requires every cycle to clear
277
+ once the payload was removed. The bend has to head for zero, though: a store
278
+ filling on top of a leak bends too, and then holds the leak's rate for as
279
+ long as the load runs. So when the last third of the window (at least three
280
+ cycles) sits at one steady rate at or above the gate, within 1.5x of itself,
281
+ the route stays `leak`. Measured on the reproduction for
282
+ [#99335](https://github.com/vercel/next.js/issues/99335), an ISR route
283
+ keeping every new path's cache control in an unbounded map: it closed on four
284
+ cycles of ~2.2 MB, twice the gate, and used to be called `saturating`, with
285
+ no issue draft. Because the shape requires every cycle to clear
275
286
  the growth gate, a decelerating curve always ends the window still growing,
276
287
  so where it settles is outside what was measured: these routes are
277
288
  **measured again** with twice the cycles, like `inconclusive` ones. No issue
@@ -388,7 +399,11 @@ A route with a revalidation period serves its cache unless the request carries
388
399
  the build's own `x-prerender-revalidate` header. Measured on that same app: the
389
400
  identical run reports `leak` with the header and `stable` without it. next-leak
390
401
  reads `previewModeId` from `.next/prerender-manifest.json` and drives those
391
- routes itself; a header you set in `next-leak.config.json` wins untouched. When
402
+ routes itself; a header you set in `next-leak.config.json` wins untouched. A
403
+ dynamic template with nothing prerendered (`generateStaticParams()` returning
404
+ `[]`) counts too: the build records no period for it, but its blocking fallback
405
+ caches every new param on first request, so `{n}` fills that cache while it is
406
+ measured and the report says so. When
392
407
  the manifest cannot supply one, the route is reported `not exercised` with no
393
408
  verdict, because a flat curve measured against a static cache says nothing about
394
409
  the app.
@@ -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;
@@ -11,7 +11,7 @@ import {
11
11
  resolveCycles,
12
12
  warrantsIssueDraft,
13
13
  withdrawnByDisagreement
14
- } from "./chunk-QUFL2FXU.js";
14
+ } from "./chunk-GU7IJUNY.js";
15
15
  import {
16
16
  assessPeakPressure,
17
17
  assessPressureVerdict,
@@ -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
@@ -93235,6 +93314,10 @@ var prerenderManifestSchema = z2.looseObject({
93235
93314
  srcRoute: z2.string().nullable().optional()
93236
93315
  })
93237
93316
  ).optional(),
93317
+ // Read only for `fallback`, and left untyped: its shape has changed across
93318
+ // Next versions (`null`, `false`, an HTML path, a PPR shell), and a template
93319
+ // the tool cannot classify must not fail the whole manifest.
93320
+ dynamicRoutes: z2.record(z2.string(), z2.looseObject({ fallback: z2.unknown().optional() })).optional(),
93238
93321
  preview: z2.looseObject({ previewModeId: z2.string() }).optional()
93239
93322
  });
93240
93323
  var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
@@ -94892,7 +94975,7 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
94892
94975
  const values = {};
94893
94976
  for (const segment of segments) {
94894
94977
  const prerendered = sampled?.[segment.name];
94895
- values[segment.name] = prerendered === void 0 ? PLACEHOLDER : varyingValueFrom(prerendered);
94978
+ values[segment.name] = varyingValueFrom(prerendered ?? PLACEHOLDER);
94896
94979
  }
94897
94980
  routes[template] = values;
94898
94981
  }
@@ -94901,6 +94984,12 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
94901
94984
  function hasPlaceholders(skeleton) {
94902
94985
  return skeleton.includes(PLACEHOLDER);
94903
94986
  }
94987
+ function writtenConfigNote(skeleton) {
94988
+ if (hasPlaceholders(skeleton)) {
94989
+ return `replace each ${PLACEHOLDER} with the shape of a value that exists in your app, and keep the \`-{n}\` after it: a fixed value measures one cache entry and reads as flat whatever the route retains`;
94990
+ }
94991
+ return "`{n}` gives every request a different value: a prerendered one serves the warm cache and reads as flat whatever the route retains. Use `{n%200}` to revisit a fixed set of keys, and drop the marker only if the app 404s on params it never prerendered.";
94992
+ }
94904
94993
 
94905
94994
  // src/unreclaimed-retention.ts
94906
94995
  var MB = 1024 * 1024;
@@ -94998,10 +95087,10 @@ function ownerLabel(attribution) {
94998
95087
  }
94999
95088
  }
95000
95089
  function revalidationLines(route) {
95001
- if (route.revalidatedEverySeconds === void 0) {
95090
+ if (route.servedFromIsrCache !== true) {
95002
95091
  return [];
95003
95092
  }
95004
- const every = `revalidates every ${route.revalidatedEverySeconds}s`;
95093
+ const every = route.revalidatedEverySeconds === void 0 ? "caches each new key on its first request" : `revalidates every ${route.revalidatedEverySeconds}s`;
95005
95094
  return route.revalidationDriven === true ? [
95006
95095
  ` driven through ISR revalidation (${every}; without it the load would serve the cache) \u2014 this is Next's revalidation path, so a leak that only happens on the path your users take is not in this number`
95007
95096
  ] : [
@@ -95032,7 +95121,7 @@ function cacheLines(route) {
95032
95121
  lines.push(
95033
95122
  ` the load served keys this route had never cached, so some of this growth is cache residency; bound it with {n%N} in next-leak.config.json`
95034
95123
  );
95035
- if (route.revalidatedEverySeconds !== void 0) {
95124
+ if (route.servedFromIsrCache === true) {
95036
95125
  lines.push(
95037
95126
  ` on this route {n%N} does not isolate that: a bounded key set is served from the ISR cache, so the run would drive revalidation and measure a different path. Compare the two runs rather than trusting either number alone`
95038
95127
  );
@@ -95173,7 +95262,7 @@ function skippedGuidanceLines(report) {
95173
95262
  if (skeleton === null) {
95174
95263
  return [];
95175
95264
  }
95176
- const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The shapes come from paths your build already prerendered; \`{n}\` makes every request use a different one, because reusing a prerendered value serves the warm cache and reads as flat whatever the route retains. Use \`{n%200}\` instead to revisit a fixed set of 200 keys, and drop the marker only if the app 404s on params it never prerendered.`;
95265
+ const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with the shape of a value that exists in your app, and keep the \`-{n}\` after it: a fixed value measures one cache entry and reads as flat whatever the route retains.` : ` The shapes come from paths your build already prerendered; \`{n}\` makes every request use a different one, because reusing a prerendered value serves the warm cache and reads as flat whatever the route retains. Use \`{n%200}\` instead to revisit a fixed set of 200 keys, and drop the marker only if the app 404s on params it never prerendered.`;
95177
95266
  return [
95178
95267
  "",
95179
95268
  `${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
@@ -95401,8 +95490,11 @@ function revalidateSecondsFor(manifest, route) {
95401
95490
  }
95402
95491
  return null;
95403
95492
  }
95493
+ function cachesOnDemand(manifest, route) {
95494
+ return manifest?.dynamicRoutes?.[route]?.fallback === null;
95495
+ }
95404
95496
  function revalidates(manifest, route) {
95405
- return revalidateSecondsFor(manifest, route) !== null;
95497
+ return revalidateSecondsFor(manifest, route) !== null || cachesOnDemand(manifest, route);
95406
95498
  }
95407
95499
  function planRevalidation(manifest, route, userHeaders, requestPath) {
95408
95500
  const userSupplied = Object.keys(userHeaders ?? {}).some(
@@ -95419,9 +95511,11 @@ function planRevalidation(manifest, route, userHeaders, requestPath) {
95419
95511
  }
95420
95512
  const previewModeId = manifest?.preview?.previewModeId;
95421
95513
  if (previewModeId === void 0 || previewModeId === "") {
95514
+ const seconds = revalidateSecondsFor(manifest, route);
95515
+ const cached = seconds === null ? "is served from the ISR cache" : `revalidates every ${seconds}s`;
95422
95516
  return {
95423
95517
  kind: "cannot-drive",
95424
- reason: `revalidates every ${revalidateSecondsFor(manifest, route)}s, but the build's prerender-manifest.json carries no previewModeId \u2014 load would serve the cache and measure nothing. Set "${REVALIDATE_HEADER}" in next-leak.config.json to drive it.`
95518
+ reason: `${cached}, but the build's prerender-manifest.json carries no previewModeId \u2014 load would serve the cache and measure nothing. Set "${REVALIDATE_HEADER}" in next-leak.config.json to drive it.`
95425
95519
  };
95426
95520
  }
95427
95521
  return { kind: "drive", headers: { [REVALIDATE_HEADER]: previewModeId } };
@@ -95539,6 +95633,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95539
95633
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
95540
95634
  const plan = planRevalidation(target.prerender, route.path, routeConfig.headers, requestPath);
95541
95635
  const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
95636
+ const servedFromIsrCache = revalidates(target.prerender, route.path);
95542
95637
  const bounded = boundedMarkerOf(requestPath);
95543
95638
  const driven = plan.kind === "drive" ? plan.headers : {};
95544
95639
  const cacheDriven = plan.kind === "no-cache-to-drive" || plan.kind === "drive" && bounded === null;
@@ -95581,7 +95676,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95581
95676
  warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
95582
95677
  // Decides whether the cache-residency remedy exists on this route: bounding
95583
95678
  // the keys of an ISR route hands the requests back to the cache.
95584
- revalidatesFromCache: revalidateSeconds !== null,
95679
+ revalidatesFromCache: servedFromIsrCache,
95585
95680
  ...routeConfig.abandonAfterMs !== void 0 && {
95586
95681
  abandonAfterMs: routeConfig.abandonAfterMs
95587
95682
  },
@@ -95624,6 +95719,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95624
95719
  memorySamples: result.memorySamples,
95625
95720
  peaks: result.peaks,
95626
95721
  ...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
95722
+ ...servedFromIsrCache && { servedFromIsrCache: true },
95627
95723
  ...plan.kind === "drive" && { revalidationDriven: true },
95628
95724
  ...bounded !== null && { keyCardinality: bounded.bound },
95629
95725
  unreclaimedSamples: result.unreclaimedSamples,
@@ -95651,7 +95747,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95651
95747
  };
95652
95748
  }
95653
95749
  async function writeEvidenceBundle(report, workDir) {
95654
- const { renderHtmlReport } = await import("./html-report-ZXDEJ6X5.js");
95750
+ const { renderHtmlReport } = await import("./html-report-VNTPHLO3.js");
95655
95751
  const { renderIssueMarkdown } = await import("./issue-report-4RW5YOT3.js");
95656
95752
  for (const route of report.routes) {
95657
95753
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -95948,7 +96044,7 @@ export {
95948
96044
  runRitual,
95949
96045
  runSelfCheck,
95950
96046
  renderConfigSkeleton,
95951
- hasPlaceholders,
96047
+ writtenConfigNote,
95952
96048
  formatReport,
95953
96049
  captureEnvironment,
95954
96050
  loadSignatures,
@@ -11,6 +11,8 @@ var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
11
11
  var ACQUITTAL_MAX_GROWTH_MULTIPLE = 8;
12
12
  var SATURATION_MIN_CYCLES = 3;
13
13
  var SATURATION_MAX_FINAL_RATIO = 0.5;
14
+ var SETTLED_TAIL_MIN_CYCLES = 3;
15
+ var SETTLED_TAIL_MAX_SPREAD = 1.5;
14
16
  function maxDrawdown(samples) {
15
17
  let peak = samples[1] ?? 0;
16
18
  let worst = 0;
@@ -46,8 +48,20 @@ function isSaturating(deltas, minGrowth) {
46
48
  if (last > first * SATURATION_MAX_FINAL_RATIO) {
47
49
  return false;
48
50
  }
51
+ if (hasSettledAboveGate(deltas, minGrowth)) {
52
+ return false;
53
+ }
49
54
  return isHeadingDown(deltas);
50
55
  }
56
+ function hasSettledAboveGate(deltas, minGrowth) {
57
+ const size = Math.max(SETTLED_TAIL_MIN_CYCLES, Math.ceil(deltas.length / 3));
58
+ const tail = deltas.slice(-size);
59
+ const smallest = Math.min(...tail);
60
+ if (smallest < minGrowth) {
61
+ return false;
62
+ }
63
+ return Math.max(...tail) <= smallest * SETTLED_TAIL_MAX_SPREAD;
64
+ }
51
65
  function hasArrivedWithoutResuming(deltas, minGrowth) {
52
66
  const stop = deltas.findIndex((delta) => delta <= 0);
53
67
  if (stop === -1) {
@@ -2,7 +2,7 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
2
2
  import {
3
3
  effectiveVerdict,
4
4
  withdrawnByDisagreement
5
- } from "./chunk-QUFL2FXU.js";
5
+ } from "./chunk-GU7IJUNY.js";
6
6
  import {
7
7
  assessPeakPressure,
8
8
  describePeakPressure,
package/dist/cli.js CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  extractModuleRegistry,
13
13
  formatReport,
14
14
  freePort,
15
- hasPlaceholders,
16
15
  helpText,
17
16
  killActiveChildren,
18
17
  parseCliArgs,
@@ -22,12 +21,13 @@ import {
22
21
  runSelfCheck,
23
22
  terminalSafe,
24
23
  unregisterChild,
25
- validateTarget
26
- } from "./chunk-UKWFONEI.js";
24
+ validateTarget,
25
+ writtenConfigNote
26
+ } from "./chunk-5OI4MUND.js";
27
27
  import "./chunk-IKWF7SSB.js";
28
28
  import {
29
29
  classifyTrend
30
- } from "./chunk-QUFL2FXU.js";
30
+ } from "./chunk-GU7IJUNY.js";
31
31
  import "./chunk-VTKOO3YJ.js";
32
32
  import "./chunk-6XYFBOL2.js";
33
33
 
@@ -907,13 +907,7 @@ ${skeleton}`
907
907
  return;
908
908
  }
909
909
  console.log(`wrote ${file}`);
910
- if (hasPlaceholders(skeleton)) {
911
- console.log("replace each REPLACE-ME with a value that exists in your app");
912
- } else {
913
- console.log(
914
- "`{n}` gives every request a different value: a prerendered one serves the warm cache and reads as flat whatever the route retains. Use `{n%200}` to revisit a fixed set of keys, and drop the marker only if the app 404s on params it never prerendered."
915
- );
916
- }
910
+ console.log(writtenConfigNote(skeleton));
917
911
  }
918
912
  async function main() {
919
913
  const parsed = parseCliArgs(process.argv.slice(2));
@@ -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[];
@@ -1,8 +1,8 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
2
  import {
3
3
  renderHtmlReport
4
- } from "./chunk-UOAW5UA5.js";
5
- import "./chunk-QUFL2FXU.js";
4
+ } from "./chunk-QNNVRQ4L.js";
5
+ import "./chunk-GU7IJUNY.js";
6
6
  import "./chunk-VTKOO3YJ.js";
7
7
  import "./chunk-6XYFBOL2.js";
8
8
  export {
package/dist/index.js CHANGED
@@ -41,14 +41,14 @@ import {
41
41
  sourceIndexAt,
42
42
  summarizeBaseline,
43
43
  validateTarget
44
- } from "./chunk-UKWFONEI.js";
44
+ } from "./chunk-5OI4MUND.js";
45
45
  import "./chunk-IKWF7SSB.js";
46
46
  import {
47
47
  renderHtmlReport
48
- } from "./chunk-UOAW5UA5.js";
48
+ } from "./chunk-QNNVRQ4L.js";
49
49
  import {
50
50
  classifyTrend
51
- } from "./chunk-QUFL2FXU.js";
51
+ } from "./chunk-GU7IJUNY.js";
52
52
  import {
53
53
  renderIssueMarkdown
54
54
  } from "./chunk-FJVY5MKV.js";
@@ -34,6 +34,9 @@ export declare const prerenderManifestSchema: z.ZodObject<{
34
34
  initialRevalidateSeconds: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<false>]>>;
35
35
  srcRoute: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
36
  }, z.core.$loose>>>;
37
+ dynamicRoutes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
38
+ fallback: z.ZodOptional<z.ZodUnknown>;
39
+ }, z.core.$loose>>>;
37
40
  preview: z.ZodOptional<z.ZodObject<{
38
41
  previewModeId: z.ZodString;
39
42
  }, z.core.$loose>>;
@@ -38,3 +38,5 @@ export declare function sampleValuesFromManifest(manifest: PrerenderManifest | u
38
38
  export declare function renderConfigSkeleton(routeTemplates: readonly string[], manifest?: PrerenderManifest | undefined): string | null;
39
39
  /** Whether a rendered skeleton still needs the user to fill anything in. */
40
40
  export declare function hasPlaceholders(skeleton: string): boolean;
41
+ /** What `--write-config` prints after writing a skeleton: how to edit it. */
42
+ export declare function writtenConfigNote(skeleton: string): string;
package/dist/runner.d.ts CHANGED
@@ -113,6 +113,12 @@ export type RouteReport = {
113
113
  * cache and one measured against a re-render are different experiments.
114
114
  */
115
115
  revalidatedEverySeconds?: number;
116
+ /**
117
+ * Set on every route served from the ISR cache, including a dynamic
118
+ * template that caches each new key with no prerendered entry to carry
119
+ * its period — the period is then absent, but the cache is in play.
120
+ */
121
+ servedFromIsrCache?: true;
116
122
  /**
117
123
  * Whether the load carried the build's own revalidation header. Set apart
118
124
  * from the period because the two answer different questions: the period
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.13.0",
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",