@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
@@ -23,7 +23,10 @@ import {
23
23
  isBrowserDebugEnabled,
24
24
  startBrowserTransaction,
25
25
  } from "./logging.js";
26
- import { validateRedirectOrigin } from "./validate-redirect-origin.js";
26
+ import {
27
+ validateRedirectOrigin,
28
+ validateExternalRedirect,
29
+ } from "./validate-redirect-origin.js";
27
30
  import {
28
31
  extractRscHeaderUrl,
29
32
  emptyResponse,
@@ -83,6 +86,9 @@ export function createServerActionBridge(
83
86
 
84
87
  // SPA-navigate when onNavigate is set, else hard-reload. state is omitted (not
85
88
  // passed as undefined) to match the header path's prior call shape.
89
+ // Callers pass an already same-origin-validated url; the hard-reload fallback
90
+ // re-validates defensively so this leaf cannot become an open redirect if a
91
+ // future caller forgets (the SPA path validates inside the navigation bridge).
86
92
  async function dispatchRedirect(url: string, state?: unknown): Promise<void> {
87
93
  if (onNavigate) {
88
94
  await onNavigate(url, {
@@ -91,7 +97,10 @@ export function createServerActionBridge(
91
97
  _skipCache: true,
92
98
  });
93
99
  } else {
94
- window.location.href = url;
100
+ const safe = validateRedirectOrigin(url, window.location.origin);
101
+ if (safe) {
102
+ window.location.href = safe;
103
+ }
95
104
  }
96
105
  }
97
106
 
@@ -161,6 +170,15 @@ export function createServerActionBridge(
161
170
  // Whether the action's response carried the keepClientCache() directive.
162
171
  // Set when the response arrives; gates the deferred invalidation below.
163
172
  let keepCache = false;
173
+ // Whether a Response actually settled from the network (the server saw the
174
+ // request). Set true as the first statement in the fetch .then() below.
175
+ // Gates the automatic invalidation: a pre-dispatch failure (encodeReply
176
+ // throw or a fetch rejection — server unreachable/DNS/connection refused)
177
+ // leaves this false, so finalizeAction() must NOT invalidate or broadcast —
178
+ // nothing reached the server, so nothing could have mutated. A failed Flight
179
+ // DECODE after the response arrived keeps it true (the mutation may have
180
+ // committed, so invalidating the now-possibly-stale client cache is correct).
181
+ let responseReceived = false;
164
182
  // Single deferred invalidation + fence release, run exactly ONCE however the
165
183
  // action terminates (normal, redirect, error, abort, intercept, concurrent).
166
184
  // This replaces main's eager clear at action start: every directive-free
@@ -176,7 +194,10 @@ export function createServerActionBridge(
176
194
  actionFinalized = true;
177
195
  // finally so a throw in invalidation cannot leak the fence (latch is set).
178
196
  try {
179
- if (!keepCache && !skipInvalidation) {
197
+ // responseReceived gates the automatic invalidation: a pre-dispatch
198
+ // failure (serialize throw / fetch reject) never reached the server, so
199
+ // marking the cache stale + broadcasting cross-tab would be spurious.
200
+ if (responseReceived && !keepCache && !skipInvalidation) {
180
201
  store.markCacheAsStaleAndBroadcast();
181
202
  }
182
203
  } finally {
@@ -263,6 +284,12 @@ export function createServerActionBridge(
263
284
  body: encodedBody,
264
285
  signal: fetchAbort.signal,
265
286
  }).then(async (response) => {
287
+ // A settled fetch promise means the request reached the server and a
288
+ // Response came back (true for 2xx, 4xx, AND 5xx — fetch only rejects
289
+ // on network-layer failure, never on HTTP status). Record it as the
290
+ // first statement so every downstream terminal can invalidate; a
291
+ // pre-dispatch failure never gets here and stays gated out.
292
+ responseReceived = true;
266
293
  // Response arrived — disconnect fetch abort from handle abort so
267
294
  // abortAllActions() doesn't disrupt the in-progress Flight stream.
268
295
  handle.signal.removeEventListener("abort", onHandleAbort);
@@ -399,6 +426,27 @@ export function createServerActionBridge(
399
426
  // Check handle.signal.aborted to avoid redirecting from a stale action
400
427
  // when the user has already navigated away.
401
428
  if (metadata?.redirect && !handle.signal.aborted) {
429
+ // Explicit off-host redirect (redirect(url, { external: true })):
430
+ // hard-navigate, but still scheme-validate (http/https only). external
431
+ // waives the same-origin check, NOT scheme safety, so a forged payload
432
+ // carrying a javascript:/data: URL cannot script via location.assign.
433
+ if (metadata.redirect.external) {
434
+ const externalUrl = validateExternalRedirect(
435
+ metadata.redirect.url,
436
+ window.location.origin,
437
+ );
438
+ if (!externalUrl) {
439
+ log("blocked external action redirect payload", {
440
+ url: metadata.redirect.url,
441
+ });
442
+ handle.complete(returnValue?.data);
443
+ return returnValue?.data;
444
+ }
445
+ log("external action redirect", { url: externalUrl });
446
+ handle.complete(returnValue?.data);
447
+ window.location.assign(externalUrl);
448
+ return returnValue?.data;
449
+ }
402
450
  const redirectUrl = validateRedirectOrigin(
403
451
  metadata.redirect.url,
404
452
  window.location.origin,
@@ -14,7 +14,6 @@ import type { RenderSegmentsOptions } from "../segment-system.js";
14
14
  export interface RscPayload<TMetadata = RscMetadata> {
15
15
  metadata?: TMetadata;
16
16
  returnValue?: ActionResult;
17
- formState?: unknown;
18
17
  }
19
18
 
20
19
  /**
@@ -91,8 +90,12 @@ export interface RscMetadata {
91
90
  basename?: string;
92
91
  /** Whether connection warmup is enabled */
93
92
  warmupEnabled?: boolean;
94
- /** Server-side redirect with optional state (for partial requests) */
95
- redirect?: { url: string };
93
+ /**
94
+ * Server-side redirect with optional state (for partial requests).
95
+ * `external: true` (from redirect(url, { external: true })) tells the client
96
+ * to hard-navigate to an off-host target instead of validating same-origin.
97
+ */
98
+ redirect?: { url: string; external?: boolean };
96
99
  /** Server-set location state to include in history.pushState */
97
100
  locationState?: Record<string, unknown>;
98
101
  }
@@ -193,6 +196,15 @@ export interface TrackedActionState {
193
196
  result: unknown | null;
194
197
  }
195
198
 
199
+ /**
200
+ * The value returned by {@link useAction} when called without a selector.
201
+ *
202
+ * This is the stable, public name for the action-state shape; consumers can
203
+ * name it in their own signatures (e.g. a wrapper hook). It aliases the
204
+ * internal {@link TrackedActionState}.
205
+ */
206
+ export type ActionState = TrackedActionState;
207
+
196
208
  /**
197
209
  * Listener for action state changes
198
210
  *
@@ -330,8 +342,14 @@ export interface RouterInstance {
330
342
  replace(url: string, options?: RouterNavigateOptions): Promise<void>;
331
343
  /** Refresh the current route (re-fetch server data, preserve client state) */
332
344
  refresh(): Promise<void>;
333
- /** Prefetch a URL for faster client-side transition */
334
- prefetch(url: string): void;
345
+ /**
346
+ * Prefetch a URL for faster client-side transition.
347
+ *
348
+ * Pass `{ key: ":source" }` to source-scope the prefetch cache entry (parity
349
+ * with `<Link prefetchKey=":source">`) when the target's response can differ
350
+ * by source page.
351
+ */
352
+ prefetch(url: string, options?: { key?: ":source" }): void;
335
353
  /** Go back in browser history */
336
354
  back(): void;
337
355
  /** Go forward in browser history */
@@ -1,29 +1,56 @@
1
+ import {
2
+ resolveSameOriginRedirect,
3
+ resolveExternalRedirect,
4
+ } from "../redirect-origin.js";
5
+
1
6
  /**
2
7
  * Validate that a client-consumed redirect URL (from headers or Flight payload)
3
8
  * targets the same origin as the current page. Prevents open-redirect attacks
4
9
  * via crafted responses.
5
10
  *
11
+ * Thin wrapper over the shared {@link resolveSameOriginRedirect} rule (also used
12
+ * by the server guard in `rsc/redirect-guard.ts`) so client and server enforce
13
+ * the identical same-origin contract. Adds the client-side `console.error` on a
14
+ * block; the resolver itself stays pure.
15
+ *
6
16
  * @returns The canonical (normalized) URL string on success, or null if blocked.
7
17
  */
8
18
  export function validateRedirectOrigin(
9
19
  url: string,
10
20
  currentOrigin: string,
11
21
  ): string | null {
12
- try {
13
- const target = new URL(url, currentOrigin);
14
- if (target.origin !== currentOrigin) {
15
- console.error(
16
- `[rango] Redirect blocked: origin mismatch (${target.origin})`,
17
- );
18
- return null;
19
- }
20
- // Return pathname+search+hash for relative inputs, full href for absolute.
21
- // This normalizes protocol-relative and other ambiguous forms.
22
- return target.href.startsWith(currentOrigin)
23
- ? target.href
24
- : target.pathname + target.search + target.hash;
25
- } catch {
26
- console.error(`[rango] Redirect blocked: invalid URL "${url}"`);
27
- return null;
22
+ const resolved = resolveSameOriginRedirect(url, currentOrigin);
23
+ if (resolved === null) {
24
+ console.error(
25
+ `[rango] Redirect blocked: cross-origin or invalid target "${url}"`,
26
+ );
27
+ }
28
+ return resolved;
29
+ }
30
+
31
+ /**
32
+ * Validate an explicit off-origin redirect (`redirect(url, { external: true })`)
33
+ * the client is about to hard-navigate to via `window.location.assign()`.
34
+ *
35
+ * Thin wrapper over the shared {@link resolveExternalRedirect} rule (also used by
36
+ * the server guard in `rsc/redirect-guard.ts`) so client and server enforce the
37
+ * identical contract: `external` allows an off-origin target but only an
38
+ * http(s) scheme. This stops a forged or mistaken external payload carrying a
39
+ * `javascript:`/`data:` URL from turning `location.assign` into a scriptable
40
+ * navigation. Adds the client-side `console.error` on a block; the resolver
41
+ * itself stays pure.
42
+ *
43
+ * @returns The normalized URL string on success, or null if blocked.
44
+ */
45
+ export function validateExternalRedirect(
46
+ url: string,
47
+ currentOrigin: string,
48
+ ): string | null {
49
+ const resolved = resolveExternalRedirect(url, currentOrigin);
50
+ if (resolved === null) {
51
+ console.error(
52
+ `[rango] External redirect blocked: non-http(s) target "${url}"`,
53
+ );
28
54
  }
55
+ return resolved;
29
56
  }
@@ -22,15 +22,14 @@ export {
22
22
  type GeneratedManifest,
23
23
  } from "./generate-manifest.js";
24
24
 
25
- export {
26
- buildRouteTrie,
27
- buildPerRouterTrie,
28
- type TrieNode,
29
- type TrieLeaf,
30
- } from "./route-trie.js";
31
-
32
- export { collectFallbackClientRefs } from "./collect-fallback-refs.js";
33
-
25
+ // buildRouteTrie / buildPerRouterTrie / collectFallbackClientRefs and the
26
+ // TrieNode/TrieLeaf types are NOT exported here: they are build-pipeline
27
+ // internals, not public API. Their only build-time consumer (the Vite
28
+ // discovery pass) imports them directly from source via a relative path
29
+ // (vite/discovery/discover-routers.ts), and the runtime RSC realm likewise
30
+ // imports route-trie.js directly (rsc/manifest-init.ts). Keeping them off the
31
+ // public ./build surface (#569 decision 6) means consumers can't mistake them
32
+ // for intended API. generateManifest* / route-types / hashParams stay public.
34
33
  export {
35
34
  writePerModuleRouteTypes,
36
35
  extractRoutesFromSource,
@@ -22,9 +22,6 @@ export interface TrieLeaf {
22
22
  sp: string;
23
23
  /** Ancestry shortCodes from root to route [M0L0, M0L0L0, M0L0L0R499] */
24
24
  a: string[];
25
- /** Optional param names declared on the route. Absent params are
26
- * omitted from the matched params record (read as `undefined`). */
27
- op?: string[];
28
25
  /** Constraint validation: paramName -> allowed values */
29
26
  cv?: Record<string, string[]>;
30
27
  /** Ordered param names for this route (positional) */
@@ -63,6 +60,9 @@ export interface TrieNode {
63
60
  * @param routeAncestry - Map of route name to ancestry shortCodes
64
61
  * @param routeToStaticPrefix - Map of route name to its entry's staticPrefix
65
62
  * @param routeTrailingSlash - Optional map of route name to trailing slash mode
63
+ * @param prerenderRouteNames - Optional set of prerendered route names (sets leaf.pr)
64
+ * @param passthroughRouteNames - Optional set of passthrough route names (sets leaf.pt)
65
+ * @param responseTypeRoutes - Optional map of route name to response type (sets leaf.rt)
66
66
  */
67
67
  export function buildRouteTrie(
68
68
  routeManifest: Record<string, string>,
@@ -97,9 +97,49 @@ export function buildRouteTrie(
97
97
  });
98
98
  }
99
99
 
100
+ sortSuffixParams(root);
100
101
  return root;
101
102
  }
102
103
 
104
+ /**
105
+ * Sort every node's suffix-param map (`node.xp`) by descending suffix length so
106
+ * the matcher tries the most specific suffix first. Overlapping suffixes like
107
+ * `.min.js` and `.js` must resolve by specificity, not route declaration order:
108
+ * a request for `/app.min.js` should match `:file.min.js`, not `:file.js`.
109
+ *
110
+ * This started as a bug — `walkTrie` iterates `node.xp` in object order and
111
+ * returns the first suffix the segment ends with, so the winner depended on
112
+ * which route was declared first. Sorting at build time fixes it allocation-free
113
+ * on the match hot path: the serialized production trie preserves this key order
114
+ * through JSON.parse, so dev (per-request rebuild) and production match
115
+ * identically. Array.prototype.sort is stable (ES2019+), so equal-length
116
+ * suffixes keep their declaration order — the router's existing tiebreak.
117
+ */
118
+ function sortSuffixParams(node: TrieNode): void {
119
+ if (node.xp) {
120
+ const sorted: Record<string, { n: string; c: TrieNode }> = {};
121
+ for (const suffix of Object.keys(node.xp).sort(
122
+ (a, b) => b.length - a.length,
123
+ )) {
124
+ sorted[suffix] = node.xp[suffix];
125
+ }
126
+ node.xp = sorted;
127
+ }
128
+ if (node.s) {
129
+ for (const child of Object.values(node.s)) {
130
+ sortSuffixParams(child);
131
+ }
132
+ }
133
+ if (node.p) {
134
+ sortSuffixParams(node.p.c);
135
+ }
136
+ if (node.xp) {
137
+ for (const child of Object.values(node.xp)) {
138
+ sortSuffixParams(child.c);
139
+ }
140
+ }
141
+ }
142
+
103
143
  /**
104
144
  * Build a per-router trie from a generated manifest. This is the single
105
145
  * construction path shared by build/discovery (discover-routers.ts, serialized
@@ -155,18 +195,14 @@ function insertRoute(
155
195
  node: TrieNode,
156
196
  segments: ParsedSegment[],
157
197
  index: number,
158
- leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
198
+ leaf: Omit<TrieLeaf, "cv" | "pa">,
159
199
  ): void {
160
- // op (full optional list) and cv (full constraint map) are route-level and
161
- // identical on every terminal, so compute them once on the shared base.
162
- const optionalParams: string[] = [];
200
+ // cv (full constraint map) is route-level and identical on every terminal,
201
+ // so compute it once on the shared base.
163
202
  const constraints: Record<string, string[]> = {};
164
203
 
165
204
  for (const seg of segments) {
166
205
  if (seg.type === "param") {
167
- if (seg.optional) {
168
- optionalParams.push(seg.value);
169
- }
170
206
  if (seg.constraint) {
171
207
  constraints[seg.value] = seg.constraint;
172
208
  }
@@ -175,7 +211,6 @@ function insertRoute(
175
211
 
176
212
  const leafBase: Omit<TrieLeaf, "pa"> = {
177
213
  ...leaf,
178
- ...(optionalParams.length > 0 ? { op: optionalParams } : {}),
179
214
  ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
180
215
  };
181
216
 
@@ -37,12 +37,15 @@ export function formatRouteEntry(
37
37
  ): string {
38
38
  const hasSearch = search && Object.keys(search).length > 0;
39
39
 
40
+ // JSON.stringify the pattern and search values so backslashes and quotes in a
41
+ // route pattern (e.g. a custom regex constraint) survive interpolation into
42
+ // both the type-level string and the runtime NamedRoutes value.
40
43
  if (!hasSearch) {
41
- return ` ${key}: "${pattern}",`;
44
+ return ` ${key}: ${JSON.stringify(pattern)},`;
42
45
  }
43
46
 
44
47
  const searchBody = Object.entries(search!)
45
- .map(([k, v]) => `${k}: "${v}"`)
48
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
46
49
  .join(", ");
47
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
50
+ return ` ${key}: { path: ${JSON.stringify(pattern)}, search: { ${searchBody} } },`;
48
51
  }
@@ -617,9 +617,6 @@ export function writeCombinedRouteTypes(
617
617
  ? readFileSync(outPath, "utf-8")
618
618
  : null;
619
619
 
620
- // When the static parser can't extract routes (e.g. callback-style urls()),
621
- // write an empty placeholder so the build-time transform's injected import
622
- // resolves. Runtime discovery will overwrite this with the real routes.
623
620
  if (Object.keys(result.routes).length === 0) {
624
621
  if (!existing) {
625
622
  const emptySource = generateRouteTypesSource({});
@@ -635,11 +632,6 @@ export function writeCombinedRouteTypes(
635
632
  hasSearchSchemas ? result.searchSchemas : undefined,
636
633
  );
637
634
  if (existing !== source) {
638
- // On initial dev startup, don't overwrite a file from runtime discovery
639
- // (which has all dynamic routes) with a smaller set from the static
640
- // parser. The static parser can't see routes generated by Array.from()
641
- // or other dynamic code. During HMR (file watcher), always write so
642
- // newly added routes appear immediately.
643
635
  if (opts?.preserveIfLarger && existing) {
644
636
  const existingCount = countPublicRouteEntries(existing);
645
637
  const newCount = Object.keys(result.routes).filter(
@@ -69,24 +69,6 @@ export function computeExpiration(
69
69
  return { staleAt, expiresAt };
70
70
  }
71
71
 
72
- // ============================================================================
73
- // Cache Key Resolution
74
- // ============================================================================
75
-
76
- /**
77
- * Resolve cache key using the 3-tier priority:
78
- * 1. keyFn (full override from route/loader cache options)
79
- * 2. store.keyGenerator (modifies default key)
80
- * 3. defaultKey (used when neither keyFn nor keyGenerator is provided)
81
- *
82
- * Errors from keyFn and store.keyGenerator propagate to the caller.
83
- * Cache identity is correctness-critical: if explicit key logic throws,
84
- * silently remapping to a different key could cause cache collisions or
85
- * serve stale/wrong data. Callers must handle the error or let it surface.
86
- *
87
- * Uses _getRequestContext (non-throwing) so that calls outside ALS
88
- * (e.g. build-time) gracefully fall back to defaultKey.
89
- */
90
72
  export async function resolveCacheKey(
91
73
  keyFn: ((ctx: RequestContext) => string | Promise<string>) | undefined,
92
74
  store: SegmentCacheStore | null,
@@ -95,34 +77,17 @@ export async function resolveCacheKey(
95
77
  ): Promise<string> {
96
78
  const requestCtx = _getRequestContext();
97
79
 
98
- // Priority 1: Route/loader-level key function (full override)
99
80
  if (keyFn && requestCtx) {
100
81
  return await keyFn(requestCtx);
101
82
  }
102
83
 
103
- // Priority 2: Store-level keyGenerator (modifies default key)
104
84
  if (store?.keyGenerator && requestCtx) {
105
85
  return await store.keyGenerator(requestCtx, defaultKey);
106
86
  }
107
87
 
108
- // Priority 3: Default key (no custom key logic provided)
109
88
  return defaultKey;
110
89
  }
111
90
 
112
- // ============================================================================
113
- // Cache Tag Resolution
114
- // ============================================================================
115
-
116
- /**
117
- * Resolve cache tags from a tags option (static array or function of ctx).
118
- *
119
- * Fails open: a thrown tag callback falls back to no tags rather than
120
- * aborting the request. Tags are additive metadata (not identity), so a
121
- * missing tag does not cause cache collisions, only a missed invalidation.
122
- *
123
- * Shared by the cache() DSL (cache-scope) and loader caching (loader-cache)
124
- * so tag resolution behaves identically across every cache axis.
125
- */
126
91
  export function resolveTagsOption<TEnv>(
127
92
  tags: string[] | ((ctx: RequestContext<TEnv>) => string[]) | undefined,
128
93
  ctx: RequestContext<TEnv> | undefined,
@@ -131,10 +96,6 @@ export function resolveTagsOption<TEnv>(
131
96
  if (!tags) return undefined;
132
97
  if (typeof tags === "function") {
133
98
  if (!ctx) {
134
- // A dynamic tags function needs the request context to run. Without it
135
- // (e.g. resolved outside a request, at build/prerender time) the entry is
136
- // cached UNTAGGED and can never be invalidated - surface that rather than
137
- // silently dropping the tags, matching the thrown-callback branch below.
138
99
  console.warn(
139
100
  `[${label}] Dynamic tags function present but no request context; ` +
140
101
  `caching without tags (this entry will not be tag-invalidatable).`,
@@ -167,25 +128,10 @@ function normalizeTagList(tags: string[]): string[] | undefined {
167
128
  return out.length > 0 ? out : undefined;
168
129
  }
169
130
 
170
- // ============================================================================
171
- // Cache Store Resolution
172
- // ============================================================================
173
-
174
- /**
175
- * Resolve cache store from the 2-tier priority:
176
- * 1. Explicit store from cache options
177
- * 2. App-level store from request context
178
- */
179
131
  export function resolveCacheStore(
180
132
  explicitStore: SegmentCacheStore | undefined,
181
133
  ): SegmentCacheStore | null {
182
134
  if (explicitStore) {
183
- // Register explicit per-scope stores so updateTag()/revalidateTag() can
184
- // reach them. This is the single chokepoint every cache axis (segment,
185
- // response, loader) resolves through, so registering here covers them all
186
- // eagerly - no dependence on whether a tagged write has happened yet. The
187
- // app-level store is intentionally not registered (always reachable via
188
- // ctx._cacheStore).
189
135
  registerExplicitTaggedStore(explicitStore);
190
136
  return explicitStore;
191
137
  }
@@ -21,6 +21,7 @@ import {
21
21
  createClientTemporaryReferenceSet,
22
22
  } from "@vitejs/plugin-rsc/rsc";
23
23
  import { getRequestContext } from "../server/request-context.js";
24
+ import { isUnderTestRunner } from "../runtime-env.js";
24
25
  import {
25
26
  isTainted,
26
27
  CACHED_FN_SYMBOL,
@@ -46,6 +47,7 @@ import {
46
47
  runWithCacheTagScope,
47
48
  } from "./cache-tag.js";
48
49
  import { reportCacheError } from "./cache-error.js";
50
+ import type { CacheItemResult } from "./types.js";
49
51
 
50
52
  /**
51
53
  * Convert encodeReply result to a stable string key.
@@ -58,6 +60,10 @@ async function replyToCacheKey(encoded: string | FormData): Promise<string> {
58
60
  return text;
59
61
  }
60
62
 
63
+ // Cached-fn ids already warned about running uncached under a test runner, so
64
+ // the test-ergonomics warning fires once per fn rather than once per call.
65
+ const warnedUncachedUnderTest = new Set<string>();
66
+
61
67
  // ============================================================================
62
68
  // Core: registerCachedFunction
63
69
  // ============================================================================
@@ -84,7 +90,28 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
84
90
  // cacheTag() call inside the function degrades to a no-op rather than
85
91
  // throwing "must be called inside a use cache function" - adopting cacheTag()
86
92
  // must not hard-fail in apps/tests without an item-capable cache configured.
93
+ // Note: the INSIDE_CACHE_EXEC guard (cookies()/headers()/ctx.set() rejection)
94
+ // is intentionally NOT stamped here. It is a cached-path-only check; in the
95
+ // bypass the body actually executes, so the guarded side effects take effect
96
+ // and nothing is lost on a (non-existent) hit. Same applies to the
97
+ // non-serializable-args bypass below.
87
98
  if (!store?.getItem) {
99
+ // Test-ergonomics guard: under a test runner, a "use cache" function that
100
+ // executes with no item-capable store seeded is exercising the UNCACHED
101
+ // path — a green test that proves nothing about caching. Warn once per fn
102
+ // id so the author knows to seed a cacheStore. Advisory (never throws), so
103
+ // a test that DELIBERATELY runs uncached is unaffected. Gated on the test
104
+ // runner (process.env.VITEST, not folded) so production never evaluates it.
105
+ if (isUnderTestRunner() && !warnedUncachedUnderTest.has(id)) {
106
+ warnedUncachedUnderTest.add(id);
107
+ console.warn(
108
+ `[rango] "use cache" function "${id}" executed but no cacheStore was ` +
109
+ `seeded; the cached path is NOT under test (it ran uncached). Pass ` +
110
+ `{ cacheStore, cacheProfiles } to runLoader/runMiddleware/renderHandler/` +
111
+ `runInRequestContext (or configure createRouter({ cache }) for dispatch) ` +
112
+ `to exercise it.`,
113
+ );
114
+ }
88
115
  const scoped = runWithCacheTagScope(() => fn.apply(this, args));
89
116
  const result = await scoped.result;
90
117
  // Still record the runtime tags into the request set so a cacheTag() in an
@@ -185,23 +212,29 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
185
212
  // Cache lookup
186
213
  const cached = await store.getItem(cacheKey);
187
214
 
215
+ // Serve a cached entry on the hit path: deserialize the stored value,
216
+ // replay handle data (gated on tainted args), and surface the entry's tags
217
+ // to the request set (the function did not re-run, so its runtime cacheTag()
218
+ // tags are only available from the stored entry). Shared by the fresh-hit
219
+ // and stale-hit branches; the only divergence is the stale branch scheduling
220
+ // background revalidation, which it does after this returns.
221
+ const serveCached = async (entry: CacheItemResult): Promise<any> => {
222
+ const result = await deserializeResult(entry.value);
223
+ if (entry.handles && hasTaintedArgs) {
224
+ const handleStore = requestCtx?._handleStore;
225
+ if (handleStore) {
226
+ const r = await decodeHandles(entry.handles);
227
+ if (r) restoreHandles(r, handleStore);
228
+ }
229
+ }
230
+ recordRequestTags(entry.tags, requestCtx);
231
+ return result;
232
+ };
233
+
188
234
  if (cached && !cached.shouldRevalidate) {
189
235
  // Fresh hit: deserialize and return
190
236
  try {
191
- const result = await deserializeResult(cached.value);
192
- // Restore handle data if present
193
- if (cached.handles && hasTaintedArgs) {
194
- const handleStore = requestCtx?._handleStore;
195
- if (handleStore) {
196
- const r = await decodeHandles(cached.handles);
197
- if (r) restoreHandles(r, handleStore);
198
- }
199
- }
200
- // Surface the hit's tags to the request set so a document built from a
201
- // cached item is still tagged (the function did not re-run, so its
202
- // runtime cacheTag() tags are only available from the stored entry).
203
- recordRequestTags(cached.tags, requestCtx);
204
- return result;
237
+ return await serveCached(cached);
205
238
  } catch (error) {
206
239
  // The stored value is corrupt/partial (failed RSC deserialize). Report
207
240
  // it, then fall through to fresh execution - the miss path below re-runs
@@ -217,16 +250,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
217
250
  if (cached?.shouldRevalidate) {
218
251
  // Stale hit: return stale value, revalidate in background
219
252
  try {
220
- const result = await deserializeResult(cached.value);
221
- if (cached.handles && hasTaintedArgs) {
222
- const handleStore = requestCtx?._handleStore;
223
- if (handleStore) {
224
- const r = await decodeHandles(cached.handles);
225
- if (r) restoreHandles(r, handleStore);
226
- }
227
- }
228
- // Tag the request with the stale entry's tags (see fresh-hit note).
229
- recordRequestTags(cached.tags, requestCtx);
253
+ const result = await serveCached(cached);
230
254
  // Background revalidation — must capture handles if tainted args present.
231
255
  // Use an isolated handle store so background pushes don't pollute the
232
256
  // live response or throw LateHandlePushError on the completed store.
@@ -34,12 +34,6 @@ import {
34
34
  } from "./cache-policy.js";
35
35
  import type { RequestContext } from "../server/request-context.js";
36
36
 
37
- /**
38
- * Resolve tags for a cache() boundary from its config (static array or
39
- * function of ctx). Thin wrapper over the shared resolveTagsOption so the
40
- * cache() DSL and loader caching resolve tags identically.
41
- * @internal
42
- */
43
37
  export function resolveCacheTags(
44
38
  config: PartialCacheOptions | false,
45
39
  ctx: RequestContext | undefined,
@@ -54,17 +48,6 @@ function debugCacheLog(message: string): void {
54
48
  }
55
49
  }
56
50
 
57
- // ============================================================================
58
- // Key Generation (internal)
59
- // ============================================================================
60
-
61
- /**
62
- * Generate cache key base from host, pathname, route params, and search params.
63
- * Host is included to prevent cross-host cache collisions on shared stores.
64
- * Route params and search params are sorted alphabetically for deterministic keys.
65
- * Internal _rsc* and __* query params are excluded.
66
- * @internal
67
- */
68
51
  function getCacheKeyBase(
69
52
  host: string,
70
53
  pathname: string,
@@ -80,16 +63,6 @@ function getCacheKeyBase(
80
63
  return key;
81
64
  }
82
65
 
83
- /**
84
- * Generate default cache key for a route request.
85
- * Includes pathname, route params, and user-facing search params for
86
- * correct scoping. Internal _rsc* params are excluded.
87
- * Includes request type prefix since they produce different segment sets:
88
- * - doc: document requests (full page load)
89
- * - partial: navigation requests (client-side navigation)
90
- * - intercept: intercept navigation (modal/overlay routes)
91
- * @internal
92
- */
93
66
  function getDefaultRouteCacheKey(
94
67
  pathname: string,
95
68
  params?: Record<string, string>,