next-leak 0.13.0 → 0.13.1

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 |
@@ -271,7 +274,15 @@ separates them, because each one has a different fix:
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.
@@ -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,
@@ -93235,6 +93235,10 @@ var prerenderManifestSchema = z2.looseObject({
93235
93235
  srcRoute: z2.string().nullable().optional()
93236
93236
  })
93237
93237
  ).optional(),
93238
+ // Read only for `fallback`, and left untyped: its shape has changed across
93239
+ // Next versions (`null`, `false`, an HTML path, a PPR shell), and a template
93240
+ // the tool cannot classify must not fail the whole manifest.
93241
+ dynamicRoutes: z2.record(z2.string(), z2.looseObject({ fallback: z2.unknown().optional() })).optional(),
93238
93242
  preview: z2.looseObject({ previewModeId: z2.string() }).optional()
93239
93243
  });
93240
93244
  var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
@@ -94892,7 +94896,7 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
94892
94896
  const values = {};
94893
94897
  for (const segment of segments) {
94894
94898
  const prerendered = sampled?.[segment.name];
94895
- values[segment.name] = prerendered === void 0 ? PLACEHOLDER : varyingValueFrom(prerendered);
94899
+ values[segment.name] = varyingValueFrom(prerendered ?? PLACEHOLDER);
94896
94900
  }
94897
94901
  routes[template] = values;
94898
94902
  }
@@ -94901,6 +94905,12 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
94901
94905
  function hasPlaceholders(skeleton) {
94902
94906
  return skeleton.includes(PLACEHOLDER);
94903
94907
  }
94908
+ function writtenConfigNote(skeleton) {
94909
+ if (hasPlaceholders(skeleton)) {
94910
+ 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`;
94911
+ }
94912
+ 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.";
94913
+ }
94904
94914
 
94905
94915
  // src/unreclaimed-retention.ts
94906
94916
  var MB = 1024 * 1024;
@@ -94998,10 +95008,10 @@ function ownerLabel(attribution) {
94998
95008
  }
94999
95009
  }
95000
95010
  function revalidationLines(route) {
95001
- if (route.revalidatedEverySeconds === void 0) {
95011
+ if (route.servedFromIsrCache !== true) {
95002
95012
  return [];
95003
95013
  }
95004
- const every = `revalidates every ${route.revalidatedEverySeconds}s`;
95014
+ const every = route.revalidatedEverySeconds === void 0 ? "caches each new key on its first request" : `revalidates every ${route.revalidatedEverySeconds}s`;
95005
95015
  return route.revalidationDriven === true ? [
95006
95016
  ` 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
95017
  ] : [
@@ -95032,7 +95042,7 @@ function cacheLines(route) {
95032
95042
  lines.push(
95033
95043
  ` 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
95044
  );
95035
- if (route.revalidatedEverySeconds !== void 0) {
95045
+ if (route.servedFromIsrCache === true) {
95036
95046
  lines.push(
95037
95047
  ` 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
95048
  );
@@ -95173,7 +95183,7 @@ function skippedGuidanceLines(report) {
95173
95183
  if (skeleton === null) {
95174
95184
  return [];
95175
95185
  }
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.`;
95186
+ 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
95187
  return [
95178
95188
  "",
95179
95189
  `${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
@@ -95401,8 +95411,11 @@ function revalidateSecondsFor(manifest, route) {
95401
95411
  }
95402
95412
  return null;
95403
95413
  }
95414
+ function cachesOnDemand(manifest, route) {
95415
+ return manifest?.dynamicRoutes?.[route]?.fallback === null;
95416
+ }
95404
95417
  function revalidates(manifest, route) {
95405
- return revalidateSecondsFor(manifest, route) !== null;
95418
+ return revalidateSecondsFor(manifest, route) !== null || cachesOnDemand(manifest, route);
95406
95419
  }
95407
95420
  function planRevalidation(manifest, route, userHeaders, requestPath) {
95408
95421
  const userSupplied = Object.keys(userHeaders ?? {}).some(
@@ -95419,9 +95432,11 @@ function planRevalidation(manifest, route, userHeaders, requestPath) {
95419
95432
  }
95420
95433
  const previewModeId = manifest?.preview?.previewModeId;
95421
95434
  if (previewModeId === void 0 || previewModeId === "") {
95435
+ const seconds = revalidateSecondsFor(manifest, route);
95436
+ const cached = seconds === null ? "is served from the ISR cache" : `revalidates every ${seconds}s`;
95422
95437
  return {
95423
95438
  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.`
95439
+ 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
95440
  };
95426
95441
  }
95427
95442
  return { kind: "drive", headers: { [REVALIDATE_HEADER]: previewModeId } };
@@ -95539,6 +95554,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95539
95554
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
95540
95555
  const plan = planRevalidation(target.prerender, route.path, routeConfig.headers, requestPath);
95541
95556
  const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
95557
+ const servedFromIsrCache = revalidates(target.prerender, route.path);
95542
95558
  const bounded = boundedMarkerOf(requestPath);
95543
95559
  const driven = plan.kind === "drive" ? plan.headers : {};
95544
95560
  const cacheDriven = plan.kind === "no-cache-to-drive" || plan.kind === "drive" && bounded === null;
@@ -95581,7 +95597,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95581
95597
  warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
95582
95598
  // Decides whether the cache-residency remedy exists on this route: bounding
95583
95599
  // the keys of an ISR route hands the requests back to the cache.
95584
- revalidatesFromCache: revalidateSeconds !== null,
95600
+ revalidatesFromCache: servedFromIsrCache,
95585
95601
  ...routeConfig.abandonAfterMs !== void 0 && {
95586
95602
  abandonAfterMs: routeConfig.abandonAfterMs
95587
95603
  },
@@ -95624,6 +95640,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95624
95640
  memorySamples: result.memorySamples,
95625
95641
  peaks: result.peaks,
95626
95642
  ...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
95643
+ ...servedFromIsrCache && { servedFromIsrCache: true },
95627
95644
  ...plan.kind === "drive" && { revalidationDriven: true },
95628
95645
  ...bounded !== null && { keyCardinality: bounded.bound },
95629
95646
  unreclaimedSamples: result.unreclaimedSamples,
@@ -95651,7 +95668,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95651
95668
  };
95652
95669
  }
95653
95670
  async function writeEvidenceBundle(report, workDir) {
95654
- const { renderHtmlReport } = await import("./html-report-ZXDEJ6X5.js");
95671
+ const { renderHtmlReport } = await import("./html-report-VNTPHLO3.js");
95655
95672
  const { renderIssueMarkdown } = await import("./issue-report-4RW5YOT3.js");
95656
95673
  for (const route of report.routes) {
95657
95674
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -95948,7 +95965,7 @@ export {
95948
95965
  runRitual,
95949
95966
  runSelfCheck,
95950
95967
  renderConfigSkeleton,
95951
- hasPlaceholders,
95968
+ writtenConfigNote,
95952
95969
  formatReport,
95953
95970
  captureEnvironment,
95954
95971
  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-GBRBWUZZ.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));
@@ -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-GBRBWUZZ.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.1",
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",