@rangojs/router 0.0.0-experimental.115 → 0.0.0-experimental.117

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.
Files changed (47) hide show
  1. package/dist/vite/index.js +148 -97
  2. package/package.json +1 -1
  3. package/skills/api-client/SKILL.md +211 -0
  4. package/skills/loader/SKILL.md +17 -17
  5. package/skills/mime-routes/SKILL.md +1 -1
  6. package/skills/rango/SKILL.md +1 -0
  7. package/skills/response-routes/SKILL.md +61 -43
  8. package/skills/typesafety/SKILL.md +3 -3
  9. package/src/__augment-tests__/augmented.check.ts +2 -3
  10. package/src/browser/navigation-client.ts +56 -68
  11. package/src/browser/prefetch/cache.ts +58 -27
  12. package/src/browser/prefetch/fetch.ts +92 -33
  13. package/src/browser/response-adapter.ts +7 -1
  14. package/src/browser/rsc-router.tsx +5 -0
  15. package/src/build/collect-fallback-refs.ts +107 -0
  16. package/src/build/generate-manifest.ts +28 -1
  17. package/src/build/index.ts +8 -1
  18. package/src/build/prefix-tree-utils.ts +123 -0
  19. package/src/build/route-trie.ts +43 -0
  20. package/src/client.tsx +4 -23
  21. package/src/errors.ts +0 -3
  22. package/src/href-client.ts +7 -8
  23. package/src/index.rsc.ts +1 -2
  24. package/src/index.ts +1 -2
  25. package/src/router/find-match.ts +54 -6
  26. package/src/router/lazy-includes.ts +33 -14
  27. package/src/router/loader-resolution.ts +63 -34
  28. package/src/router/manifest.ts +19 -6
  29. package/src/router/pattern-matching.ts +15 -2
  30. package/src/router/router-interfaces.ts +11 -0
  31. package/src/router/trie-matching.ts +22 -3
  32. package/src/router.ts +21 -7
  33. package/src/rsc/manifest-init.ts +28 -41
  34. package/src/rsc/response-error.ts +79 -12
  35. package/src/rsc/response-route-handler.ts +16 -13
  36. package/src/server/context.ts +32 -0
  37. package/src/server/request-context.ts +47 -9
  38. package/src/types/loader-types.ts +6 -3
  39. package/src/urls/index.ts +1 -2
  40. package/src/urls/type-extraction.ts +33 -24
  41. package/src/vite/discovery/discover-routers.ts +46 -29
  42. package/src/vite/discovery/state.ts +7 -0
  43. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  44. package/src/vite/rango.ts +32 -4
  45. package/src/vite/utils/client-chunks.ts +41 -7
  46. package/src/vite/utils/manifest-utils.ts +8 -75
  47. package/src/vite/utils/shared-utils.ts +58 -0
package/src/vite/rango.ts CHANGED
@@ -23,7 +23,10 @@ import {
23
23
  onwarn,
24
24
  getManualChunks,
25
25
  } from "./utils/shared-utils.js";
26
- import { resolveClientChunks } from "./utils/client-chunks.js";
26
+ import {
27
+ resolveClientChunks,
28
+ type ClientChunkContext,
29
+ } from "./utils/client-chunks.js";
27
30
  import type { RangoOptions } from "./plugin-types.js";
28
31
  import { printBanner, rangoVersion } from "./utils/banner.js";
29
32
  import { createVersionInjectorPlugin } from "./plugins/version-injector.js";
@@ -69,9 +72,17 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
69
72
  // @vitejs/plugin-rsc in both presets. The built-in strategy only splits where it
70
73
  // recognizes a route structure, so this default is a no-op for flat / host-split
71
74
  // apps and never duplicates the shared runtime.
72
- const clientChunks = resolveClientChunks(
73
- resolvedOptions.clientChunks ?? true,
74
- );
75
+ const clientChunksOption = resolvedOptions.clientChunks ?? true;
76
+ // Shared context the built-in strategy reads at build time: the production
77
+ // hashes of registered error/notFound fallback modules (-> app-fallback).
78
+ // Populated by the discovery plugin in buildStart, before the client build
79
+ // invokes the strategy. Only wired when the built-in strategy is active; a
80
+ // custom function owns its own grouping.
81
+ const useBuiltInClientChunks = clientChunksOption === true;
82
+ const clientChunkCtx: ClientChunkContext | undefined = useBuiltInClientChunks
83
+ ? { fallbackRefs: new Set<string>() }
84
+ : undefined;
85
+ const clientChunks = resolveClientChunks(clientChunksOption, clientChunkCtx);
75
86
  debugConfig?.("rango(%s) setup start", preset);
76
87
 
77
88
  const plugins: PluginOption[] = [];
@@ -157,6 +168,14 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
157
168
  client: {
158
169
  build: {
159
170
  rollupOptions: {
171
+ // FILE_NAME_CONFLICT (and any other client-build warning) is
172
+ // emitted by the CLIENT environment build, which consults THIS
173
+ // env's onwarn -- Vite 8's environment builds do NOT propagate
174
+ // the top-level build.rollupOptions.onwarn into the client env.
175
+ // Wire it here so the suppression runs where the conflicts
176
+ // originate (the top-level handler is invoked 0x for these; the
177
+ // client-env handler is invoked for all of them).
178
+ onwarn,
160
179
  output: {
161
180
  manualChunks: getManualChunks,
162
181
  },
@@ -317,6 +336,14 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
317
336
  client: {
318
337
  build: {
319
338
  rollupOptions: {
339
+ // FILE_NAME_CONFLICT (and any other client-build warning) is
340
+ // emitted by the CLIENT environment build, which consults THIS
341
+ // env's onwarn -- Vite 8's environment builds do NOT propagate
342
+ // the top-level build.rollupOptions.onwarn into the client env.
343
+ // Wire it here so the suppression runs where the conflicts
344
+ // originate (the top-level handler is invoked 0x for these; the
345
+ // client-env handler is invoked for all of them).
346
+ onwarn,
320
347
  output: {
321
348
  manualChunks: getManualChunks,
322
349
  },
@@ -508,6 +535,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
508
535
  enableBuildPrerender: prerenderEnabled,
509
536
  buildEnv: options?.buildEnv,
510
537
  preset,
538
+ clientChunkCtx,
511
539
  }),
512
540
  );
513
541
 
@@ -6,10 +6,28 @@
6
6
 
7
7
  import type { ClientChunkMeta, ClientChunks } from "../plugin-types.js";
8
8
  import { createRangoDebugger, NS } from "../debug.js";
9
+ import { hashRefKey } from "../plugins/client-ref-hashing.js";
9
10
 
10
11
  /** The callback shape @vitejs/plugin-rsc's `clientChunks` option accepts. */
11
12
  export type RscClientChunksFn = (meta: ClientChunkMeta) => string | undefined;
12
13
 
14
+ /**
15
+ * Build-time context the discovery pass populates and the built-in strategy
16
+ * reads. It refines how the catch-all (no route-root marker) modules are grouped
17
+ * without touching marker splits or the shared runtime:
18
+ *
19
+ * - `fallbackRefs`: production hashes of the `"use client"` modules a consumer
20
+ * registered as `errorBoundary`/`notFoundBoundary` fallbacks. Pulled into a
21
+ * dedicated `app-fallback` chunk so the error UI is not co-bundled with the
22
+ * very route code it exists to catch failures for (resilience), and so the
23
+ * chunk it would otherwise sit in gets named after a real module rather than
24
+ * the boundary. Populated by reading each fallback element's client-reference
25
+ * `$$id` during discovery (see discover-routers).
26
+ */
27
+ export interface ClientChunkContext {
28
+ fallbackRefs: Set<string>;
29
+ }
30
+
13
31
  /**
14
32
  * Opt-in observability for the built-in strategy. The route-root marker list is
15
33
  * intentionally finite (see {@link ROUTE_ROOT_DIRS}); a consumer whose layout
@@ -101,18 +119,31 @@ const ROUTE_ROOT_DIRS = new Set([
101
119
  * `app-components`), re-introducing cross-app leakage.
102
120
  *
103
121
  * Resolution order:
104
- * 1. If the path passes through a {@link ROUTE_ROOT_DIRS} marker that has a
105
- * directory after it, key on that next segment (the route id) robust to any
106
- * nesting depth below it (`routes/foo/components/ui/X.tsx` -> `app-foo`).
107
- * 2. Otherwise return `undefined` (inherit the default `serverChunk` grouping).
122
+ * 1. Shared runtime (React / router / node_modules) -> `undefined` (never split).
123
+ * 2. A registered error/notFound fallback (`ctx.fallbackRefs`) -> `app-fallback`,
124
+ * regardless of location, so the error UI is decoupled from the happy path.
125
+ * 3. A {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on that
126
+ * next segment (the route id), robust to any nesting depth.
127
+ * 4. Otherwise `undefined` (inherit the default `serverChunk` grouping).
108
128
  */
109
129
  export function directoryClientChunks(
110
130
  meta: ClientChunkMeta,
131
+ ctx?: ClientChunkContext,
111
132
  ): string | undefined {
112
133
  if (isSharedRuntime(meta)) {
113
134
  // React / router runtime / node_modules: always shared, expected, uninteresting.
114
135
  return undefined;
115
136
  }
137
+ // Registered error/notFound fallbacks -> a dedicated chunk. The error UI must
138
+ // not co-bundle with the code it catches failures for, and removing it lets the
139
+ // chunk it would otherwise anchor be named after a real module, not the boundary.
140
+ if (
141
+ ctx?.fallbackRefs.size &&
142
+ ctx.fallbackRefs.has(hashRefKey(meta.normalizedId))
143
+ ) {
144
+ debugChunks?.("fallback %s -> app-fallback", meta.normalizedId);
145
+ return "app-fallback";
146
+ }
116
147
  const segments = meta.normalizedId.split("/").filter(Boolean);
117
148
  const dirCount = segments.length - 1; // exclude the filename
118
149
  if (dirCount >= 1) {
@@ -144,13 +175,16 @@ export function directoryClientChunks(
144
175
  * grouping.
145
176
  *
146
177
  * - `false` / `undefined` -> `undefined` (no override).
147
- * - `true` -> the built-in {@link directoryClientChunks} strategy.
148
- * - function -> the user's function, used verbatim.
178
+ * - `true` -> the built-in {@link directoryClientChunks} strategy,
179
+ * bound to the discovery-populated {@link ClientChunkContext} (fallback chunk).
180
+ * - function -> the user's function, used verbatim (full control; the
181
+ * fallback refinement does not apply — the consumer owns the grouping).
149
182
  */
150
183
  export function resolveClientChunks(
151
184
  option: ClientChunks | undefined,
185
+ ctx?: ClientChunkContext,
152
186
  ): RscClientChunksFn | undefined {
153
187
  if (!option) return undefined;
154
- if (option === true) return directoryClientChunks;
188
+ if (option === true) return (meta) => directoryClientChunks(meta, ctx);
155
189
  return option;
156
190
  }
@@ -1,78 +1,11 @@
1
- /**
2
- * Flatten prefix tree leaf nodes into precomputed route entries.
3
- * Leaf nodes have no children (no nested includes), so their routes can be
4
- * used directly by evaluateLazyEntry() without running the handler.
5
- * Non-leaf nodes are skipped because they have nested lazy includes that
6
- * require the handler to run for discovery.
7
- *
8
- * A leaf is also skipped when its staticPrefix collides with an ancestor
9
- * include node's staticPrefix. That happens when a dynamic param collapses the
10
- * staticPrefix of nested includes onto the parent's (e.g. `/m/:id/edit` -> sp
11
- * `/m`): precomputing such a leaf under the collapsed prefix would let the
12
- * ancestor's lazy entry claim a route it cannot register (the route is behind
13
- * further nested lazy includes), producing a RouteNotFoundError at request time
14
- * (issue #506). Those routes are resolved via the handler chain instead.
15
- */
16
- export function flattenLeafEntries(
17
- prefixTree: Record<string, any>,
18
- routeManifest: Record<string, string>,
19
- result: Array<{ staticPrefix: string; routes: Record<string, string> }>,
20
- ): void {
21
- function visit(node: any, ancestorStaticPrefixes: Set<string>): void {
22
- const children = node.children || {};
23
- if (
24
- Object.keys(children).length === 0 &&
25
- node.routes &&
26
- node.routes.length > 0
27
- ) {
28
- // Leaf node. Skip if its staticPrefix collides with an ancestor include
29
- // node's staticPrefix (dynamic-param collapse) — see doc comment above.
30
- if (ancestorStaticPrefixes.has(node.staticPrefix)) {
31
- return;
32
- }
33
- // Collect its routes from the manifest
34
- const routes: Record<string, string> = {};
35
- for (const name of node.routes) {
36
- if (name in routeManifest) {
37
- routes[name] = routeManifest[name];
38
- }
39
- }
40
- result.push({ staticPrefix: node.staticPrefix, routes });
41
- } else {
42
- // Non-leaf: recurse into children, tracking this node's staticPrefix as
43
- // an ancestor so a collapsed nested leaf below it is not over-claimed.
44
- const nextAncestors = new Set(ancestorStaticPrefixes);
45
- nextAncestors.add(node.staticPrefix);
46
- for (const child of Object.values(children)) {
47
- visit(child, nextAncestors);
48
- }
49
- }
50
- }
51
- for (const node of Object.values(prefixTree)) {
52
- visit(node, new Set());
53
- }
54
- }
55
-
56
- /**
57
- * Walk prefix tree to map each route name to its scope's staticPrefix.
58
- */
59
- export function buildRouteToStaticPrefix(
60
- prefixTree: Record<string, any>,
61
- result: Record<string, string>,
62
- ): void {
63
- function visit(node: any): void {
64
- const sp = node.staticPrefix || "";
65
- for (const name of node.routes || []) {
66
- result[name] = sp;
67
- }
68
- for (const child of Object.values(node.children || {})) {
69
- visit(child);
70
- }
71
- }
72
- for (const node of Object.values(prefixTree)) {
73
- visit(node);
74
- }
75
- }
1
+ // Pure prefix-tree walks live in the build layer so runtime code can consume
2
+ // them without importing from vite/. Re-exported here for the vite-side
3
+ // callers (discover-routers, virtual-module-codegen) that already import them
4
+ // from this module.
5
+ export {
6
+ flattenLeafEntries,
7
+ buildRouteToStaticPrefix,
8
+ } from "../../build/prefix-tree-utils.js";
76
9
 
77
10
  /**
78
11
  * Wrap a value as `JSON.parse('...')` instead of a JS object literal.
@@ -116,6 +116,50 @@ export function createVirtualEntriesPlugin(
116
116
  };
117
117
  }
118
118
 
119
+ // Matches rollup's FILE_NAME_CONFLICT message and reports whether the colliding
120
+ // file is a content-hashed asset, e.g.
121
+ // The emitted file "assets/index-DlGNrvnU.css" overwrites a previously ...
122
+ // The emitted file "assets/inter-latin-Dx4kXJAl.woff2" overwrites a ...
123
+ // The match is UNANCHORED on purpose: by the time the warning reaches this user
124
+ // onwarn handler, Vite's logger has wrapped rollup's raw message with an ANSI
125
+ // color sequence and a "[CODE] " label, e.g.
126
+ // "[FILE_NAME_CONFLICT] The emitted file \"...\" overwrites ..."
127
+ // A "^The emitted file" anchor sits behind that prefix and never matches; and
128
+ // Vite also strips the JSON.stringify quotes rollup puts around the filename, so
129
+ // the match is UNANCHORED and quote-OPTIONAL ("?...?"?). The non-whitespace
130
+ // capture stops at the space before "overwrites" (Vite's unquoted display form)
131
+ // or the closing quote (raw rollup form); either way it carries no ANSI.
132
+ // A content-hashed name ends with a "-" separator + a Vite content hash. The
133
+ // hash is a FIXED-LENGTH base64url run ([A-Za-z0-9_-], default 8), so it can
134
+ // itself contain "-"/"_": it CANNOT be located by splitting on the last "-"
135
+ // (that lands inside the hash whenever it carries a dash, e.g. "...-Cabi7G8-" ->
136
+ // "" or "...-CkhJZR-_" -> "_", which let those conflicts leak). Instead take the
137
+ // trailing HASH_LEN chars and require the "-" separator right before them. The
138
+ // hash must hold an uppercase letter or digit (a real hash is never an
139
+ // all-lowercase word), so stable names like "assets/manifest.json" or
140
+ // "assets/loading-skeleton.css" still surface as potential genuine overwrites.
141
+ function isContentHashedAssetConflict(message: string | undefined): boolean {
142
+ if (!message) return false;
143
+ const match =
144
+ /The emitted file "?([^"\s]+)"? overwrites a previously emitted file/.exec(
145
+ message,
146
+ );
147
+ if (!match) return false;
148
+ const fileName = match[1];
149
+ const base = fileName.slice(fileName.lastIndexOf("/") + 1);
150
+ const dot = base.lastIndexOf(".");
151
+ if (dot <= 0) return false;
152
+ const stem = base.slice(0, dot);
153
+ // HASH_LEN tracks Vite's default [hash] width; bump it if an app sets a custom
154
+ // assetFileNames hash length.
155
+ const HASH_LEN = 8;
156
+ if (stem.length < HASH_LEN + 1 || stem[stem.length - HASH_LEN - 1] !== "-") {
157
+ return false;
158
+ }
159
+ const hash = stem.slice(-HASH_LEN);
160
+ return /^[A-Za-z0-9_-]+$/.test(hash) && /[A-Z0-9]/.test(hash);
161
+ }
162
+
119
163
  /**
120
164
  * Rollup onwarn handler that suppresses known harmless warnings:
121
165
  * - "use client" directives: handled by the RSC plugin, not relevant to Rollup
@@ -126,6 +170,14 @@ export function createVirtualEntriesPlugin(
126
170
  * by the bundler, rather than the vite:reporter message handled below (Rollup/Vite 7 shape).
127
171
  * - empty bundle: @vitejs/plugin-rsc scan build (step 1/5) produces an empty "index" chunk
128
172
  * because the RSC entry is fully externalized during client-reference analysis
173
+ * - file name conflicts on content-hashed assets: @vitejs/plugin-rsc copies the rsc
174
+ * environment's imported CSS/assets into the client bundle (its assets-manifest
175
+ * generateBundle re-emits each via emitFile with an explicit content-hashed
176
+ * fileName). When the client bundle already produced that identical asset,
177
+ * rollup raises FILE_NAME_CONFLICT even though the bytes are identical (a
178
+ * content hash collision IS a content match). Only these are suppressed; a
179
+ * collision on a stable name still surfaces. No upstream fix as of
180
+ * @vitejs/plugin-rsc@0.5.27; remove when it skips the redundant emit.
129
181
  */
130
182
  export function onwarn(
131
183
  warning: Vite.Rollup.RollupLog,
@@ -139,6 +191,12 @@ export function onwarn(
139
191
  ) {
140
192
  return;
141
193
  }
194
+ if (
195
+ warning.code === "FILE_NAME_CONFLICT" &&
196
+ isContentHashedAssetConflict(warning.message)
197
+ ) {
198
+ return;
199
+ }
142
200
  // @vitejs/plugin-rsc@0.5.14: rsc:virtual:vite-rsc/assets-manifest renderChunk
143
201
  // returns { code } without map, causing Rollup to warn about incorrect sourcemaps.
144
202
  // This is harmless (simple string replacement). Remove this suppression if a