@rangojs/router 0.30.0 → 0.31.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.
@@ -26,6 +26,8 @@ import {
26
26
  } from "./include-resolution.js";
27
27
  import { findUrlsVariableNames } from "./per-module-writer.js";
28
28
  import { isAutoGeneratedRouteName } from "../../route-name.js";
29
+ import { normalizeBasename } from "../../router/basename.js";
30
+ import { getStringValue } from "./ast-helpers.js";
29
31
 
30
32
  function countPublicRouteEntries(source: string): number {
31
33
  const matches =
@@ -232,15 +234,16 @@ function collectCreateRouterCallChain(
232
234
  }
233
235
  }
234
236
 
235
- function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
236
- const sourceFile = ts.createSourceFile(
237
- "router.tsx",
238
- code,
239
- ts.ScriptTarget.Latest,
240
- true,
241
- ts.ScriptKind.TSX,
242
- );
243
- let result: UrlsExtractionResult[] | null = null;
237
+ // The first createRouter chain (in source order) that mounts urls, with its
238
+ // extractions in runtime order. Route types and basename both come from it.
239
+ function findRouterUrls(sourceFile: ts.SourceFile): {
240
+ createRouterCall: ts.CallExpression;
241
+ extractions: UrlsExtractionResult[];
242
+ } | null {
243
+ let result: {
244
+ createRouterCall: ts.CallExpression;
245
+ extractions: UrlsExtractionResult[];
246
+ } | null = null;
244
247
 
245
248
  function visit(node: ts.Node) {
246
249
  if (result) return;
@@ -279,7 +282,7 @@ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
279
282
  }
280
283
 
281
284
  if (extracted.length > 0) {
282
- result = extracted;
285
+ result = { createRouterCall, extractions: extracted };
283
286
  return;
284
287
  }
285
288
  }
@@ -288,7 +291,18 @@ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
288
291
  }
289
292
 
290
293
  visit(sourceFile);
291
- return result ?? [];
294
+ return result;
295
+ }
296
+
297
+ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
298
+ const sourceFile = ts.createSourceFile(
299
+ "router.tsx",
300
+ code,
301
+ ts.ScriptTarget.Latest,
302
+ true,
303
+ ts.ScriptKind.TSX,
304
+ );
305
+ return findRouterUrls(sourceFile)?.extractions ?? [];
292
306
  }
293
307
 
294
308
  /**
@@ -307,48 +321,189 @@ export function extractUrlsFromRouter(
307
321
  return extractions[extractions.length - 1] ?? null;
308
322
  }
309
323
 
324
+ function unwrapTypeOperators(expr: ts.Expression): ts.Expression {
325
+ while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) {
326
+ expr = expr.expression;
327
+ }
328
+ return expr;
329
+ }
330
+
331
+ function hasModifier(statement: ts.Statement, kind: ts.SyntaxKind): boolean {
332
+ return (
333
+ ts.canHaveModifiers(statement) &&
334
+ (ts.getModifiers(statement)?.some((m) => m.kind === kind) ?? false)
335
+ );
336
+ }
337
+
338
+ // `exported` limits the lookup to `export const name = ...`; an export list or
339
+ // re-export is not followed.
340
+ function findTopLevelConst(
341
+ sourceFile: ts.SourceFile,
342
+ name: string,
343
+ exported: boolean,
344
+ ): ts.Expression | undefined {
345
+ for (const statement of sourceFile.statements) {
346
+ if (
347
+ ts.isVariableStatement(statement) &&
348
+ statement.declarationList.flags & ts.NodeFlags.Const &&
349
+ !hasModifier(statement, ts.SyntaxKind.DeclareKeyword) &&
350
+ (!exported || hasModifier(statement, ts.SyntaxKind.ExportKeyword))
351
+ ) {
352
+ for (const decl of statement.declarationList.declarations) {
353
+ if (ts.isIdentifier(decl.name) && decl.name.text === name) {
354
+ return decl.initializer && unwrapTypeOperators(decl.initializer);
355
+ }
356
+ }
357
+ }
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ // The value import binding `name` in the router file: a named import
363
+ // (`import { X }` / `import { Y as X }`). Read from the AST, so a commented-out
364
+ // or quoted import never matches. Default and namespace imports are not read.
365
+ function findNamedImport(
366
+ sourceFile: ts.SourceFile,
367
+ name: string,
368
+ ): { specifier: string; exportedName: string } | undefined {
369
+ for (const statement of sourceFile.statements) {
370
+ if (
371
+ !ts.isImportDeclaration(statement) ||
372
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
373
+ statement.importClause?.isTypeOnly
374
+ ) {
375
+ continue;
376
+ }
377
+ const bindings = statement.importClause?.namedBindings;
378
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
379
+ for (const el of bindings.elements) {
380
+ if (!el.isTypeOnly && el.name.text === name) {
381
+ return {
382
+ specifier: statement.moduleSpecifier.text,
383
+ exportedName: el.propertyName?.text ?? name,
384
+ };
385
+ }
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ // The top-level const `name` refers to: declared in the router file or
392
+ // exported as a const by a relative module it is imported from by name.
393
+ function resolveTopLevelConst(
394
+ name: string,
395
+ sourceFile: ts.SourceFile,
396
+ routerFilePath: string,
397
+ ): ts.Expression | undefined {
398
+ const local = findTopLevelConst(sourceFile, name, false);
399
+ if (local) return local;
400
+ const imported = findNamedImport(sourceFile, name);
401
+ if (!imported) return undefined;
402
+ const target = resolveImportPath(imported.specifier, routerFilePath);
403
+ if (!target) return undefined;
404
+ const targetSource = readFileSync(target, "utf-8");
405
+ return findTopLevelConst(
406
+ ts.createSourceFile(target, targetSource, ts.ScriptTarget.Latest, true),
407
+ imported.exportedName,
408
+ true,
409
+ );
410
+ }
411
+
412
+ // Inside any nested scope a local declaration can shadow the top-level
413
+ // binding, and resolving it anyway would emit a wrong prefix.
414
+ function isTopLevelScope(node: ts.Node): boolean {
415
+ for (let p = node.parent; !ts.isSourceFile(p); p = p.parent) {
416
+ if (
417
+ ts.isFunctionLike(p) ||
418
+ ts.isClassLike(p) ||
419
+ ts.isBlock(p) ||
420
+ ts.isCaseBlock(p) ||
421
+ ts.isModuleBlock(p) ||
422
+ ts.isIterationStatement(p, false)
423
+ ) {
424
+ return false;
425
+ }
426
+ }
427
+ return true;
428
+ }
429
+
310
430
  /**
311
- * Extract the `basename` string literal from createRouter({ basename: "..." }).
312
- * Returns the basename value or undefined if not present.
431
+ * Read inline createRouter({ basename: X }) where X is a string literal, a
432
+ * top-level const, or `Const.prop` on a top-level const object literal.
433
+ * `unresolved` is the source of any other X.
313
434
  */
314
- export function extractBasenameFromRouter(code: string): string | undefined {
435
+ function resolveRouterBasename(
436
+ routerSource: string,
437
+ routerFilePath: string,
438
+ ): { value?: string; unresolved?: string } {
315
439
  const sourceFile = ts.createSourceFile(
316
- "router.tsx",
317
- code,
440
+ routerFilePath,
441
+ routerSource,
318
442
  ts.ScriptTarget.Latest,
319
443
  true,
320
444
  ts.ScriptKind.TSX,
321
445
  );
322
- let result: string | undefined;
323
-
324
- function visit(node: ts.Node) {
325
- if (result !== undefined) return;
326
- if (
327
- ts.isCallExpression(node) &&
328
- ts.isIdentifier(node.expression) &&
329
- node.expression.text === "createRouter"
446
+ // Read from the createRouter call whose urls the route types use. Only an
447
+ // explicit `basename` key counts: one inside a spread
448
+ // (`createRouter({ ...shared })`) or a non-literal options argument stays
449
+ // unread and unreported.
450
+ const options = findRouterUrls(sourceFile)?.createRouterCall.arguments[0];
451
+ const prop =
452
+ options && ts.isObjectLiteralExpression(options)
453
+ ? options.properties.find(
454
+ (p): p is ts.PropertyAssignment | ts.ShorthandPropertyAssignment =>
455
+ (ts.isPropertyAssignment(p) ||
456
+ ts.isShorthandPropertyAssignment(p)) &&
457
+ ts.isIdentifier(p.name) &&
458
+ p.name.text === "basename",
459
+ )
460
+ : undefined;
461
+ if (!prop) return {};
462
+
463
+ const valueExpr = ts.isPropertyAssignment(prop)
464
+ ? prop.initializer
465
+ : prop.name;
466
+ const expr = unwrapTypeOperators(valueExpr);
467
+ let value = getStringValue(expr);
468
+ if (value === null && isTopLevelScope(prop)) {
469
+ if (ts.isIdentifier(expr)) {
470
+ const init = resolveTopLevelConst(expr.text, sourceFile, routerFilePath);
471
+ value = init ? getStringValue(init) : null;
472
+ } else if (
473
+ ts.isPropertyAccessExpression(expr) &&
474
+ ts.isIdentifier(expr.expression)
330
475
  ) {
331
- for (const arg of node.arguments) {
332
- if (ts.isObjectLiteralExpression(arg)) {
333
- for (const prop of arg.properties) {
334
- if (
335
- ts.isPropertyAssignment(prop) &&
336
- ts.isIdentifier(prop.name) &&
337
- prop.name.text === "basename" &&
338
- ts.isStringLiteral(prop.initializer)
339
- ) {
340
- result = prop.initializer.text;
341
- return;
342
- }
343
- }
344
- }
345
- }
476
+ const owner = resolveTopLevelConst(
477
+ expr.expression.text,
478
+ sourceFile,
479
+ routerFilePath,
480
+ );
481
+ value = owner ? resolveConstMember(owner, expr.name.text) : null;
346
482
  }
347
- ts.forEachChild(node, visit);
348
483
  }
484
+ return value === null
485
+ ? { unresolved: valueExpr.getText(sourceFile).replace(/\s+/g, " ") }
486
+ : { value };
487
+ }
349
488
 
350
- visit(sourceFile);
351
- return result;
489
+ // String value of `owner.name` when owner is an object literal. A spread may
490
+ // override the property, so an owner containing one is not resolved.
491
+ function resolveConstMember(owner: ts.Expression, name: string): string | null {
492
+ if (
493
+ !ts.isObjectLiteralExpression(owner) ||
494
+ owner.properties.some(ts.isSpreadAssignment)
495
+ ) {
496
+ return null;
497
+ }
498
+ const member = owner.properties.find(
499
+ (p): p is ts.PropertyAssignment =>
500
+ ts.isPropertyAssignment(p) &&
501
+ ts.isIdentifier(p.name) &&
502
+ p.name.text === name,
503
+ );
504
+ return member
505
+ ? getStringValue(unwrapTypeOperators(member.initializer))
506
+ : null;
352
507
  }
353
508
 
354
509
  /** Apply a basename prefix to all route patterns in a result set. */
@@ -496,11 +651,9 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
496
651
  return { routes: {}, searchSchemas: {} };
497
652
  }
498
653
 
499
- // Detect basename from createRouter({ basename: "..." })
500
- const rawBasename = extractBasenameFromRouter(routerSource);
501
- const basename = rawBasename
502
- ? ("/" + rawBasename.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "")
503
- : undefined;
654
+ const basename = normalizeBasename(
655
+ resolveRouterBasename(routerSource, routerFilePath).value,
656
+ );
504
657
 
505
658
  const result: RouteMapWithSearch = { routes: {}, searchSchemas: {} };
506
659
  const memo = createScanMemo();
@@ -531,8 +684,9 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
531
684
 
532
685
  /**
533
686
  * Walk the full include tree starting from a router file and detect
534
- * all includes that the static parser cannot resolve.
535
- * Returns an array of diagnostics; empty means fully resolvable.
687
+ * all includes that the static parser cannot resolve, plus an explicit
688
+ * createRouter `basename` it cannot read (every pattern would lack the
689
+ * prefix). Returns an array of diagnostics; empty means fully resolvable.
536
690
  */
537
691
  export function detectUnresolvableIncludes(
538
692
  routerFilePath: string,
@@ -559,6 +713,16 @@ export function detectUnresolvableIncludes(
559
713
  memo,
560
714
  );
561
715
  }
716
+ const { unresolved } = resolveRouterBasename(source, realPath);
717
+ if (unresolved !== undefined) {
718
+ diagnostics.push({
719
+ pathPrefix: "/",
720
+ namePrefix: null,
721
+ reason: "unresolvable-basename",
722
+ sourceFile: realPath,
723
+ detail: `basename \`${unresolved}\` is not a string literal or top-level const; every pattern would lack the prefix`,
724
+ });
725
+ }
562
726
  return diagnostics;
563
727
  }
564
728
 
@@ -1,5 +1,6 @@
1
- import { resolve } from "node:path";
1
+ import { basename, resolve } from "node:path";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import type { Plugin, PluginOption, ViteDevServer } from "vite";
3
4
  import {
4
5
  generateRouteTypesSource,
5
6
  genFileTsPath,
@@ -35,13 +36,10 @@ export async function discoverAndWriteRouteTypes(
35
36
  routeCount: number;
36
37
  outputFiles: string[];
37
38
  }> {
38
- let createViteServer: typeof import("vite").createServer;
39
39
  let loadConfigFromFile: typeof import("vite").loadConfigFromFile;
40
- let rsc: any;
41
40
 
42
41
  try {
43
42
  const vite = await import("vite");
44
- createViteServer = vite.createServer;
45
43
  loadConfigFromFile = vite.loadConfigFromFile;
46
44
  } catch {
47
45
  throw new Error(
@@ -50,61 +48,57 @@ export async function discoverAndWriteRouteTypes(
50
48
  }
51
49
 
52
50
  try {
53
- const rscMod = await import("@vitejs/plugin-rsc");
54
- rsc = rscMod.default;
51
+ await import("@vitejs/plugin-rsc");
55
52
  } catch {
56
53
  throw new Error(
57
54
  "Runtime discovery requires '@vitejs/plugin-rsc'. Install it with: pnpm add -D @vitejs/plugin-rsc",
58
55
  );
59
56
  }
60
57
 
61
- const { createVersionPlugin } =
62
- await import("../vite/plugins/version-plugin.ts");
63
- const { createVirtualStubPlugin } =
64
- await import("../vite/plugins/virtual-stub-plugin.ts");
58
+ const { createTempRscServer } = await import("../vite/temp-rsc-server.js");
59
+ const { createDiscoveryState } = await import("../vite/discovery/state.js");
60
+ const { discoverClientUrlProjections } =
61
+ await import("../vite/discovery/client-urls-projection.js");
62
+ const { pickForwardedRunnerConfig, selectForwardableResolvePlugins } =
63
+ await import("../vite/utils/forward-user-plugins.js");
65
64
 
66
- // Load user's vite config to get resolve.alias (unless provided directly)
67
- let userResolveAlias: any = opts.resolveAlias;
68
- if (!userResolveAlias) {
69
- const configPath = opts.configFile;
65
+ // The discovery temp server of `vite dev/build` (expose-internal-ids, the
66
+ // cloudflare:* stubs, the user's resolver plugins and resolve/define/oxc).
67
+ // The config stays unresolved: resolving it would run rango()'s
68
+ // configResolved, which writes the static gen files. Forwarded resolvers
69
+ // therefore run without their own config/configResolved hooks.
70
+ const entryPath = resolve(opts.entry);
71
+ const state = createDiscoveryState(entryPath, undefined);
72
+ state.projectRoot = opts.root;
73
+ state.userResolveAlias = opts.resolveAlias;
74
+ if (!opts.resolveAlias) {
70
75
  try {
71
76
  const loaded = await loadConfigFromFile(
72
77
  { command: "serve", mode: "development" },
73
- configPath,
78
+ opts.configFile,
74
79
  opts.root,
75
80
  );
76
- if (loaded?.config?.resolve?.alias) {
77
- userResolveAlias = loaded.config.resolve.alias;
81
+ if (loaded) {
82
+ state.userRunnerConfig = pickForwardedRunnerConfig(loaded.config);
83
+ state.userResolvePlugins = selectForwardableResolvePlugins(
84
+ await flattenPluginOptions(loaded.config.plugins),
85
+ );
78
86
  }
79
- } catch {
80
- // Config loading failed; proceed without aliases
87
+ } catch (err: any) {
88
+ // Discovery without the app's resolvers fails later on an unrelated
89
+ // import; name the cause here.
90
+ console.warn(
91
+ `[rango] Could not load the Vite config (${err?.message ?? err}); discovering without its resolve config and plugins`,
92
+ );
81
93
  }
82
94
  }
83
95
 
84
- const entryPath = resolve(opts.entry);
85
- let tempServer: any = null;
96
+ let tempServer: ViteDevServer | null = null;
86
97
 
87
98
  try {
88
- tempServer = await createViteServer({
89
- root: opts.root,
90
- configFile: false,
91
- server: { middlewareMode: true },
92
- appType: "custom",
93
- logLevel: "silent",
99
+ tempServer = await createTempRscServer(state, {
94
100
  cacheDir: "node_modules/.vite_rango_generate",
95
- resolve: userResolveAlias ? { alias: userResolveAlias } : undefined,
96
- esbuild: { jsx: "automatic", jsxImportSource: "react" },
97
- plugins: [
98
- rsc({
99
- entries: {
100
- client: "virtual:entry-client",
101
- ssr: "virtual:entry-ssr",
102
- rsc: entryPath,
103
- },
104
- }),
105
- createVersionPlugin(),
106
- createVirtualStubPlugin(),
107
- ],
101
+ plugins: [missingGenFileStubPlugin()],
108
102
  });
109
103
 
110
104
  const rscEnv = (tempServer.environments as any)?.rsc;
@@ -125,6 +119,14 @@ export async function discoverAndWriteRouteTypes(
125
119
  );
126
120
  }
127
121
 
122
+ // include() of a clientUrls() module resolves from these projections.
123
+ await discoverClientUrlProjections(
124
+ state,
125
+ (tempServer.environments as any)?.ssr,
126
+ serverMod,
127
+ rscEnv,
128
+ );
129
+
128
130
  // Import build utilities for manifest generation
129
131
  const buildMod = await rscEnv.runner.import("@rangojs/router/build");
130
132
  const generateManifestFull = buildMod.generateManifestFull;
@@ -149,7 +151,11 @@ export async function discoverAndWriteRouteTypes(
149
151
  const mountManifests = [];
150
152
  for (const mount of mounts) {
151
153
  mountManifests.push(
152
- await generateManifestFull(mount.patterns, globalMountIndex),
154
+ await generateManifestFull(
155
+ mount.patterns,
156
+ globalMountIndex,
157
+ router.__basename ? { urlPrefix: router.__basename } : undefined,
158
+ ),
153
159
  );
154
160
  globalMountIndex++;
155
161
  }
@@ -232,3 +238,46 @@ export async function discoverAndWriteRouteTypes(
232
238
  }
233
239
  }
234
240
  }
241
+
242
+ const MISSING_GEN_PREFIX = "\0rango-missing-named-routes-gen:";
243
+
244
+ /**
245
+ * expose-router-id makes every createRouter() file import its sibling
246
+ * `./<name>.named-routes.gen.js`. `vite dev/build` write those from static
247
+ * parsing before discovery; the CLI does not, so a missing one resolves to an
248
+ * empty NamedRoutes here and discovery writes the real file. An existing gen
249
+ * file loads from disk as usual.
250
+ */
251
+ function missingGenFileStubPlugin(): Plugin {
252
+ return {
253
+ name: "@rangojs/router:missing-gen-file-stub",
254
+ enforce: "pre",
255
+ resolveId(id, importer) {
256
+ if (!importer || !id.endsWith(".named-routes.gen.js")) return null;
257
+ const genPath = genFileTsPath(importer.split("?", 1)[0]);
258
+ if (id !== `./${basename(genPath, ".ts")}.js` || existsSync(genPath)) {
259
+ return null;
260
+ }
261
+ return MISSING_GEN_PREFIX + genPath;
262
+ },
263
+ load(id) {
264
+ return id.startsWith(MISSING_GEN_PREFIX)
265
+ ? "export const NamedRoutes = {};\n"
266
+ : null;
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Flatten an unresolved `plugins` option (nested arrays, promises, falsy
273
+ * entries) the way Vite's config resolution does, without running any hook.
274
+ */
275
+ async function flattenPluginOptions(
276
+ option: PluginOption | undefined,
277
+ ): Promise<Plugin[]> {
278
+ const value = await option;
279
+ if (Array.isArray(value)) {
280
+ return (await Promise.all(value.map(flattenPluginOptions))).flat();
281
+ }
282
+ return value ? [value as Plugin] : [];
283
+ }
@@ -170,8 +170,10 @@ function serializeSearchSchema(
170
170
  throw projectionError(route, 'path option "search" must be an object');
171
171
  }
172
172
 
173
+ // Source order, not sorted: the build writes the gen file's search keys from
174
+ // this projection and must match `rango generate`'s static parse (#1322).
173
175
  const entries: Array<[string, SearchSchemaValue]> = [];
174
- for (const key of Object.keys(value).sort()) {
176
+ for (const key of Object.keys(value)) {
175
177
  const schemaValue = (value as Record<string, unknown>)[key];
176
178
  if (
177
179
  typeof schemaValue !== "string" ||
@@ -233,10 +233,24 @@ const handedSeen = new Map<string, WeakSet<object>>();
233
233
  // Thenables the router handed unread that React found already settled when
234
234
  // it read them: it suspended on a value that was there. The one cause of a
235
235
  // fallback the router owns (I3); consumed by the fallback it caused.
236
- const needless = new WeakSet<object>();
236
+ let needless = new WeakSet<object>();
237
237
 
238
238
  let report: SuspenseAuditReport | undefined;
239
239
 
240
+ /**
241
+ * One loaded copy of this module. A test that re-imports the module after
242
+ * vi.resetModules() runs two, and the one its static imports use is not the
243
+ * one on `__rangoSuspenseAudit`. The router's unit-test lock resets and
244
+ * checks each (src/__tests__/kit/suspense-audit-lock.ts).
245
+ */
246
+ export interface SuspenseAuditInstance {
247
+ /** The violation counters above zero and the last events, if any. */
248
+ violations(): string | undefined;
249
+ forget(): void;
250
+ /** Resolves once the judgments queued so far have run. */
251
+ settle(): Promise<void>;
252
+ }
253
+
240
254
  function getReport(): SuspenseAuditReport {
241
255
  if (report) return report;
242
256
  const created: SuspenseAuditReport = {
@@ -277,9 +291,16 @@ function getReport(): SuspenseAuditReport {
277
291
  };
278
292
  report = created;
279
293
  if (typeof window !== "undefined") {
280
- (
281
- window as unknown as { __rangoSuspenseAudit?: SuspenseAuditReport }
282
- ).__rangoSuspenseAudit = created;
294
+ const target = window as unknown as {
295
+ __rangoSuspenseAudit?: SuspenseAuditReport;
296
+ __rangoSuspenseAudits?: SuspenseAuditInstance[];
297
+ };
298
+ target.__rangoSuspenseAudit = created;
299
+ (target.__rangoSuspenseAudits ??= []).push({
300
+ violations: () => violationSummary(created),
301
+ forget: forgetSuspenseAudit,
302
+ settle: () => new Promise((resolve) => afterMicrotasks(resolve)),
303
+ });
283
304
  }
284
305
  return created;
285
306
  }
@@ -306,17 +327,35 @@ const RULES: Record<SuspenseAuditKind, [invariant: string, counter: Counter]> =
306
327
  mutated: ["I7", "mutations"],
307
328
  };
308
329
 
330
+ function eventLine(
331
+ kind: SuspenseAuditKind,
332
+ boundary: string,
333
+ detail: string,
334
+ ): string {
335
+ return `${RULES[kind][0]} ${kind} at ${boundary}: ${detail}`;
336
+ }
337
+
338
+ function violationSummary(r: SuspenseAuditReport): string | undefined {
339
+ const counts = Object.values(RULES)
340
+ .filter(([, counter]) => r[counter] > 0)
341
+ .map(([invariant, counter]) => `${invariant} ${counter}: ${r[counter]}`);
342
+ if (counts.length === 0) return undefined;
343
+ const events = r.events.map(
344
+ (event) => ` ${eventLine(event.kind, event.boundary, event.detail)}`,
345
+ );
346
+ return [counts.join(", "), ...events].join("\n");
347
+ }
348
+
309
349
  function violate(
310
350
  kind: SuspenseAuditKind,
311
351
  boundary: string,
312
352
  detail: string,
313
353
  ): void {
314
- const [invariant, counter] = RULES[kind];
315
354
  const r = getReport();
316
- r[counter] += 1;
355
+ r[RULES[kind][1]] += 1;
317
356
  r.events.push({ kind, boundary, detail, url: href(), at: performance.now() });
318
357
  if (r.events.length > MAX_EVENTS) r.events.shift();
319
- console.error(`${PREFIX} ${invariant} ${kind} at ${boundary}: ${detail}`);
358
+ console.error(`${PREFIX} ${eventLine(kind, boundary, detail)}`);
320
359
  }
321
360
 
322
361
  // Every hand-over, mount and fallback, for a debug run to read back. Call
@@ -617,9 +656,9 @@ let pendingRoot: PromiseLike<unknown> | undefined;
617
656
  // I7: the fields of each segment object handed to React, at hand-over, and
618
657
  // the segments themselves, weakly: the history cache keeps a tree's segments
619
658
  // after the next tree drops them, and a write there is still a write.
620
- const handedFields = new WeakMap<object, Record<string, unknown>>();
659
+ let handedFields = new WeakMap<object, Record<string, unknown>>();
621
660
  const handedSegments = new Set<WeakRef<object>>();
622
- const trackedSegments = new WeakSet<object>();
661
+ let trackedSegments = new WeakSet<object>();
623
662
 
624
663
  function heldTree(): TreeAudit | undefined {
625
664
  if (pendingRoot) {
@@ -1016,15 +1055,23 @@ export function auditTreeUpdate(root?: unknown): void {
1016
1055
  }
1017
1056
  }
1018
1057
 
1019
- /** Forget every tree and mount seen so far. For tests of the audit itself. */
1058
+ /**
1059
+ * Forget every tree, mount, hand-over and count seen so far: the root that
1060
+ * held them is gone. A test unmounts its root, production never does
1061
+ * (testing/render-route.tsx, src/__tests__/kit/suspense-audit-lock.ts).
1062
+ * Creates no report: with the audit off there is none.
1063
+ */
1020
1064
  export function forgetSuspenseAudit(): void {
1021
1065
  held = undefined;
1022
1066
  handedSegments.clear();
1067
+ handedFields = new WeakMap();
1068
+ trackedSegments = new WeakSet();
1069
+ needless = new WeakSet();
1023
1070
  pendingRoot = undefined;
1024
1071
  treeCause = undefined;
1025
1072
  unmounted.clear();
1026
1073
  remounted.length = 0;
1027
1074
  settledSinceTree.clear();
1028
1075
  mountCounts.clear();
1029
- getReport().reset();
1076
+ report?.reset();
1030
1077
  }
@@ -128,7 +128,7 @@ import { isUnderTestRunner } from "../runtime-env.js";
128
128
  import { setupNavigationBridgeDelegatedPrefetch } from "../browser/navigation-bridge.js";
129
129
  import { resetPrefetchObserverForTesting } from "../browser/prefetch/observer.js";
130
130
  import type { PrefetchStrategy } from "../router/prefetch-default.js";
131
- import { auditTreeCause } from "../suspense-audit.js";
131
+ import { auditTreeCause, forgetSuspenseAudit } from "../suspense-audit.js";
132
132
 
133
133
  const TEST_ORIGIN = "http://localhost";
134
134
  let activePrefetchRegistrations = 0;
@@ -1186,6 +1186,10 @@ export async function renderRoute(
1186
1186
  ...makeMetadata(url.pathname, initialSegments, initialMatch.params),
1187
1187
  defaultPrefetch: options.defaultPrefetch,
1188
1188
  };
1189
+ // The suspense audit holds one document's tree and mounts: a root an earlier
1190
+ // renderRoute mounted is gone (a test unmounts its root, production never
1191
+ // does). A no-op unless this repo's tests run the audit.
1192
+ forgetSuspenseAudit();
1189
1193
  const initialTree = await renderSegments(initialSegments, {
1190
1194
  outletPending: options.outletPending,
1191
1195
  });