@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.
- package/dist/bin/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/dist/bin/rango.js +7861 -197
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/include-resolution.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +3 -7
- package/dist/types/suspense-audit.d.ts +19 -1
- package/dist/types/vite/discovery/build-versions.d.ts +17 -13
- package/dist/types/vite/plugins/stable-rsc-output.d.ts +1 -1
- package/dist/types/vite/temp-rsc-server.d.ts +8 -4
- package/dist/types/vite/utils/forward-user-plugins.d.ts +3 -1
- package/dist/vite/index.js +153 -50
- package/package.json +2 -2
- package/skills/rango/SKILL.md +5 -5
- package/skills/testing/response-routes.md +3 -2
- package/skills/typesafety/generated-files-and-cli.md +2 -1
- package/src/bin/rango.ts +13 -8
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-types/include-resolution.ts +2 -1
- package/src/build/route-types/router-processing.ts +213 -49
- package/src/build/runtime-discovery.ts +90 -41
- package/src/client-urls/server-projection.ts +3 -1
- package/src/suspense-audit.ts +58 -11
- package/src/testing/render-route.tsx +5 -1
- package/src/vite/discovery/build-versions.ts +67 -26
- package/src/vite/plugins/stable-rsc-output.ts +1 -1
- package/src/vite/temp-rsc-server.ts +9 -4
- package/src/vite/utils/forward-user-plugins.ts +4 -2
|
@@ -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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
*
|
|
312
|
-
*
|
|
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
|
-
|
|
435
|
+
function resolveRouterBasename(
|
|
436
|
+
routerSource: string,
|
|
437
|
+
routerFilePath: string,
|
|
438
|
+
): { value?: string; unresolved?: string } {
|
|
315
439
|
const sourceFile = ts.createSourceFile(
|
|
316
|
-
|
|
317
|
-
|
|
440
|
+
routerFilePath,
|
|
441
|
+
routerSource,
|
|
318
442
|
ts.ScriptTarget.Latest,
|
|
319
443
|
true,
|
|
320
444
|
ts.ScriptKind.TSX,
|
|
321
445
|
);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 {
|
|
62
|
-
|
|
63
|
-
const {
|
|
64
|
-
await import("../vite/
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
78
|
+
opts.configFile,
|
|
74
79
|
opts.root,
|
|
75
80
|
);
|
|
76
|
-
if (loaded
|
|
77
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
85
|
-
let tempServer: any = null;
|
|
96
|
+
let tempServer: ViteDevServer | null = null;
|
|
86
97
|
|
|
87
98
|
try {
|
|
88
|
-
tempServer = await
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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" ||
|
package/src/suspense-audit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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[
|
|
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} ${
|
|
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
|
-
|
|
659
|
+
let handedFields = new WeakMap<object, Record<string, unknown>>();
|
|
621
660
|
const handedSegments = new Set<WeakRef<object>>();
|
|
622
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
});
|