@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
@@ -1,18 +1,10 @@
1
1
  /**
2
2
  * Deterministic param hashing for prerender storage keys.
3
- *
4
- * Used at build time (child process) to generate filenames and at
5
- * runtime (worker) to look up pre-rendered data. Both environments
6
- * must produce identical hashes for the same params.
7
- *
8
- * Uses a simple DJB2-based hash that works in all JS environments
9
- * (Node.js, Cloudflare Workers, browsers) without crypto imports.
3
+ * Used at build time and runtime; both must produce identical hashes.
4
+ * DJB2-based; works in all JS environments without crypto imports.
10
5
  */
11
6
 
12
- /**
13
- * Compute a deterministic hash string from route params.
14
- * For static routes (no params), returns "_".
15
- */
7
+ // For static routes (no params), returns "_".
16
8
  export function hashParams(params: Record<string, string>): string {
17
9
  const entries = Object.entries(params);
18
10
  if (entries.length === 0) return "_";
@@ -27,6 +19,13 @@ export function hashParams(params: Record<string, string>): string {
27
19
  /**
28
20
  * DJB2 hash returning an 8-char hex string.
29
21
  * Deterministic across all JS runtimes.
22
+ *
23
+ * 32-bit output: per-route collision probability hits ~50% near ~77k distinct
24
+ * param sets (birthday bound). The production store keys solely on
25
+ * routeName/paramHash and does not verify the canonical param string, so a
26
+ * collision serves the surviving entry for both param sets. Benign for typical
27
+ * catalogs; revisit (wider hash or stored-param verification) before
28
+ * pre-rendering hundreds of thousands of pages per route.
30
29
  */
31
30
  function djb2Hex(str: string): string {
32
31
  let hash = 5381;
@@ -1,11 +1,7 @@
1
1
  /**
2
- * Prerender Store
3
- *
4
- * Reads pre-rendered segment data from the worker bundle at build time.
5
- * The manifest module is lazily loaded via globalThis.__loadPrerenderManifestModule,
6
- * a function injected into the RSC entry that returns the manifest module
7
- * containing a key-to-specifier map and a `loadPrerenderAsset` function
8
- * that anchors import() resolution relative to the manifest file.
2
+ * Prerender Store — reads pre-rendered segment data from the worker bundle.
3
+ * Manifest module (injected via globalThis.__loadPrerenderManifestModule)
4
+ * contains key-to-specifier map and loadPrerenderAsset for import() resolution.
9
5
  */
10
6
 
11
7
  import type { SerializedSegmentData } from "../cache/types.js";
@@ -101,13 +97,20 @@ export function createPrerenderStore(): PrerenderStore | null {
101
97
  if (!globalThis.__loadPrerenderManifestModule) return null;
102
98
 
103
99
  const cache = new Map<string, Promise<PrerenderEntry | null>>();
104
- let manifestModulePromise: Promise<PrerenderManifestModule | null> | null =
105
- null;
100
+ let manifestModulePromise: Promise<PrerenderManifestModule> | null = null;
106
101
 
107
- function loadManifestModule(): Promise<PrerenderManifestModule | null> {
102
+ function loadManifestModule(): Promise<PrerenderManifestModule> {
108
103
  if (!manifestModulePromise) {
104
+ // Do not cache a failed manifest-module load: clear the memoized promise
105
+ // on rejection so the next get() retries, and let the error propagate
106
+ // (consistent with the per-asset load policy below) instead of caching a
107
+ // null for the isolate lifetime, which would silently degrade every
108
+ // prerendered route to a miss after one transient failure.
109
109
  manifestModulePromise = globalThis.__loadPrerenderManifestModule!().catch(
110
- () => null,
110
+ (err) => {
111
+ manifestModulePromise = null;
112
+ throw err;
113
+ },
111
114
  );
112
115
  }
113
116
  return manifestModulePromise;
@@ -120,7 +123,6 @@ export function createPrerenderStore(): PrerenderStore | null {
120
123
  if (cached) return cached;
121
124
 
122
125
  const promise = loadManifestModule().then((mod) => {
123
- if (!mod) return null;
124
126
  const specifier = mod.default[key];
125
127
  if (!specifier) return null;
126
128
  // Let asset load errors propagate — a missing/corrupted artifact
@@ -129,29 +131,20 @@ export function createPrerenderStore(): PrerenderStore | null {
129
131
  // (which the handler stub would misreport as a 404).
130
132
  return mod.loadPrerenderAsset(specifier).then((asset) => asset.default);
131
133
  });
132
- cache.set(key, promise);
134
+ // Only memoize once the manifest module resolved: a manifest-load
135
+ // rejection must not poison the per-key cache, or the retry above is moot.
136
+ cache.set(
137
+ key,
138
+ promise.catch((err) => {
139
+ cache.delete(key);
140
+ throw err;
141
+ }),
142
+ );
133
143
  return promise;
134
144
  },
135
145
  };
136
146
  }
137
147
 
138
- /**
139
- * Load the prerender manifest index for test introspection.
140
- * Returns the key→specifier map or null if unavailable.
141
- */
142
- export async function loadPrerenderManifestIndex(): Promise<Record<
143
- string,
144
- string
145
- > | null> {
146
- if (!globalThis.__loadPrerenderManifestModule) return null;
147
- try {
148
- const mod = await globalThis.__loadPrerenderManifestModule();
149
- return mod.default;
150
- } catch {
151
- return null;
152
- }
153
- }
154
-
155
148
  /**
156
149
  * Create a static segment store.
157
150
  * Production only: backed by globalThis.__STATIC_MANIFEST injected at build time.
package/src/prerender.ts CHANGED
@@ -442,6 +442,40 @@ export function isPrerenderPassthrough(
442
442
  );
443
443
  }
444
444
 
445
+ /**
446
+ * Detect whether any resolved segment carries the passthrough sentinel.
447
+ *
448
+ * A build handler signals passthrough by returning `ctx.passthrough()` (the
449
+ * PRERENDER_PASSTHROUGH sentinel), which lands on the segment's `component`.
450
+ * But when the route declares `loading()`, the handler result is deferred
451
+ * upstream (segment-resolution/fresh.ts), so `component` is a thenable resolving
452
+ * to the sentinel rather than the sentinel itself — a synchronous
453
+ * `isPrerenderPassthrough(component)` on the Promise returns false and the build
454
+ * bakes a corrupt artifact instead of deferring. Resolve thenables first.
455
+ *
456
+ * Rejections are swallowed here: a throwing build handler resurfaces during
457
+ * segment serialization, preserving the prior error-handling behavior.
458
+ */
459
+ export async function detectPrerenderPassthrough(
460
+ segments: ReadonlyArray<{ component: unknown }>,
461
+ ): Promise<boolean> {
462
+ for (const seg of segments) {
463
+ let component: unknown = seg.component;
464
+ if (
465
+ component &&
466
+ typeof (component as { then?: unknown }).then === "function"
467
+ ) {
468
+ try {
469
+ component = await component;
470
+ } catch {
471
+ continue;
472
+ }
473
+ }
474
+ if (isPrerenderPassthrough(component)) return true;
475
+ }
476
+ return false;
477
+ }
478
+
445
479
  // -- Type guards ------------------------------------------------------------
446
480
 
447
481
  /**
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Runtime-neutral same-origin redirect rule.
3
+ *
4
+ * Shared by the client redirect guard (`browser/validate-redirect-origin.ts`,
5
+ * which validates redirect targets the client JS is about to navigate to) and
6
+ * the server outgoing-redirect guard (`rsc/redirect-guard.ts`, which validates
7
+ * every browser-followed `Location` header before it leaves the handler). Kept
8
+ * at the `src/` root so both layers import the ONE rule and cannot drift -- a
9
+ * cross-origin target blocked on the JS/fetch path is blocked identically on the
10
+ * no-JS (PE) and full-page document paths.
11
+ */
12
+
13
+ /**
14
+ * Resolve a redirect target against the current origin.
15
+ *
16
+ * Returns the canonical (normalized) same-origin href -- which also collapses
17
+ * protocol-relative (`//evil.com`) and other ambiguous forms -- or `null` when
18
+ * the target resolves to a different origin or is unparseable. Pure: no logging,
19
+ * no side effects.
20
+ */
21
+ export function resolveSameOriginRedirect(
22
+ url: string,
23
+ currentOrigin: string,
24
+ ): string | null {
25
+ try {
26
+ const target = new URL(url, currentOrigin);
27
+ if (target.origin !== currentOrigin) {
28
+ return null;
29
+ }
30
+ return target.href;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Validate an explicit off-origin redirect target (`redirect(url, { external:
38
+ * true })`).
39
+ *
40
+ * `external` opts out of the same-origin rule, but NOT out of scheme safety:
41
+ * only `http:`/`https:` targets are allowed. A redirect ultimately reaches the
42
+ * browser via `window.location.assign()` on the SPA/action client paths, so a
43
+ * forged or mistaken `redirect("javascript:...", { external: true })` would be a
44
+ * scriptable navigation if the scheme were not checked here. Returns the
45
+ * normalized href for an http(s) target (same- or cross-origin), or `null`
46
+ * otherwise. Pure: no logging, no side effects.
47
+ */
48
+ export function resolveExternalRedirect(
49
+ url: string,
50
+ currentOrigin: string,
51
+ ): string | null {
52
+ try {
53
+ const target = new URL(url, currentOrigin);
54
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
55
+ return null;
56
+ }
57
+ return target.href;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Out-of-band brand for `redirect(url, { external: true })`.
65
+ *
66
+ * The external opt-in MUST be settable only by app code calling `redirect(...,
67
+ * { external: true })`, never by an attacker. An earlier design carried the
68
+ * opt-in as a wire header (`x-rango-redirect-external`), but a wire header is
69
+ * forgeable: a proxy-style response route that copies an attacker-controlled
70
+ * upstream response's headers would let `302 Location: https://evil` plus that
71
+ * header bypass the same-origin guard without app code ever opting in. So the
72
+ * opt-in is now an out-of-band brand on the Response object itself, tracked in a
73
+ * `WeakSet` that cannot cross the wire. `redirect()` brands the Response; the
74
+ * small set of internal redirect-rebuild paths (middleware `mergeResponse`,
75
+ * `carryOverRedirectHeaders`, the response-route rewrap) transfer the brand onto
76
+ * the rebuilt Response; the guard and the SPA intercept read it. An upstream
77
+ * Response an app proxies is never branded, so its forged header is inert.
78
+ *
79
+ * Fail-closed: if a rebuild path ever drops the brand, the redirect is
80
+ * neutralized to the app root rather than allowed off-host.
81
+ */
82
+ const externalRedirects = new WeakSet<Response>();
83
+
84
+ /** Brand a Response as an explicit `{ external: true }` redirect (out-of-band). */
85
+ export function markExternalRedirect(response: Response): void {
86
+ externalRedirects.add(response);
87
+ }
88
+
89
+ /** Read the out-of-band `{ external: true }` brand off a Response. */
90
+ export function isExternalRedirect(response: Response): boolean {
91
+ return externalRedirects.has(response);
92
+ }
93
+
94
+ /**
95
+ * Reserved internal header name. No longer a trust signal -- the external
96
+ * opt-in is the out-of-band brand above. It is kept only so the redirect-rebuild
97
+ * paths and the guard can defensively strip any value (e.g. one forged by a
98
+ * proxied upstream) and guarantee it never reaches the browser.
99
+ */
100
+ export const EXTERNAL_REDIRECT_MARKER: string = "x-rango-redirect-external";
@@ -3,26 +3,17 @@
3
3
  import { Component, useState, type ReactNode } from "react";
4
4
  import type { ClientErrorBoundaryFallbackProps } from "./types.js";
5
5
 
6
- /**
7
- * Check if an error is a network-related error
8
- */
9
6
  function isNetworkError(error: Error): boolean {
10
7
  return error.name === "NetworkError";
11
8
  }
12
9
 
13
- /**
14
- * Network error fallback UI with retry functionality
15
- * Shows a connection-specific message and allows retrying via page refresh
16
- */
17
10
  function NetworkErrorFallback({
18
11
  error,
19
- reset,
20
12
  }: ClientErrorBoundaryFallbackProps): ReactNode {
21
13
  const [isRetrying, setIsRetrying] = useState(false);
22
14
 
23
15
  const handleRetry = (): void => {
24
16
  setIsRetrying(true);
25
- // Refresh the page to retry the request
26
17
  window.location.reload();
27
18
  };
28
19
 
@@ -42,7 +33,6 @@ function NetworkErrorFallback({
42
33
  marginBottom: "1rem",
43
34
  }}
44
35
  >
45
- {/* Simple cloud with x icon using CSS */}
46
36
  <span style={{ color: "#9ca3af" }}>&#9729;</span>
47
37
  </div>
48
38
  <h1
@@ -101,10 +91,6 @@ function NetworkErrorFallback({
101
91
  );
102
92
  }
103
93
 
104
- /**
105
- * Default fallback UI for root error boundary
106
- * This is shown when an unhandled error bubbles up to the root
107
- */
108
94
  function RootErrorFallback({
109
95
  error,
110
96
  reset,
@@ -230,7 +216,6 @@ export class RootErrorBoundary extends Component<
230
216
  }
231
217
 
232
218
  componentDidMount(): void {
233
- // Listen for popstate (back/forward navigation) to reset error state
234
219
  window.addEventListener("popstate", this.handlePopState);
235
220
  }
236
221
 
@@ -247,15 +232,13 @@ export class RootErrorBoundary extends Component<
247
232
  }
248
233
 
249
234
  componentDidUpdate(prevProps: { children: ReactNode }): void {
250
- // Reset error state when children change (e.g., navigation)
251
- // This allows the app to recover after navigation away from an errored route
235
+ // Reset error on children change (navigation).
252
236
  if (this.state.hasError && prevProps.children !== this.props.children) {
253
237
  this.setState({ hasError: false, error: null });
254
238
  }
255
239
  }
256
240
 
257
241
  handlePopState = (): void => {
258
- // Reset error state on back/forward navigation
259
242
  if (this.state.hasError) {
260
243
  this.setState({ hasError: false, error: null });
261
244
  }
@@ -276,7 +259,6 @@ export class RootErrorBoundary extends Component<
276
259
  segmentType: "route" as const,
277
260
  };
278
261
 
279
- // Use specialized fallback for network errors
280
262
  if (isNetworkError(this.state.error)) {
281
263
  return <NetworkErrorFallback error={errorInfo} reset={this.reset} />;
282
264
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import type { ReactNode } from "react";
3
- import { Suspense, use, useId } from "react";
3
+ import { Suspense, use } from "react";
4
4
  import { invariant } from "./errors";
5
5
  import { OutletProvider } from "./outlet-provider.js";
6
6
  import type { ResolvedSegment } from "./types.js";
@@ -36,37 +36,6 @@ export function RouteContentWrapper({
36
36
  );
37
37
  }
38
38
 
39
- export function RouteContentWrapperCallback<T>({
40
- resolve,
41
- fallback,
42
- children,
43
- }: {
44
- resolve: Promise<T> | T;
45
- fallback?: ReactNode;
46
- children: (data: T) => ReactNode;
47
- }): ReactNode {
48
- const id = useId();
49
- invariant(children, "RouteContentWrapperCallback requires children");
50
- invariant(
51
- typeof children === "function",
52
- "RouteContentWrapperCallback requires children to be a function",
53
- );
54
- invariant(
55
- resolve !== undefined,
56
- "RouteContentWrapperCallback requires resolve",
57
- );
58
- return (
59
- <Suspense
60
- fallback={fallback ?? null}
61
- key={"route-content-suspense-callback-" + id}
62
- >
63
- <SuspenderCallback resolve={resolve} key={id}>
64
- {children}
65
- </SuspenderCallback>
66
- </Suspense>
67
- );
68
- }
69
-
70
39
  const Suspender = ({
71
40
  content,
72
41
  }: {
@@ -77,18 +46,6 @@ const Suspender = ({
77
46
  return use(content);
78
47
  };
79
48
 
80
- const SuspenderCallback = <T,>({
81
- resolve,
82
- children,
83
- }: {
84
- resolve: Promise<T> | T;
85
- children: (data: T) => ReactNode;
86
- }): ReactNode => {
87
- return resolve instanceof Promise
88
- ? children(use(resolve))
89
- : children(resolve);
90
- };
91
-
92
49
  /**
93
50
  * LoaderBoundary - Client component that resolves loader promises and renders OutletProvider
94
51
  *
@@ -302,15 +302,15 @@ const when: RouteHelpers<any, any>["when"] = (fn) => {
302
302
  * Supports these call signatures:
303
303
  * - cache() - no args, uses app-level defaults (for loader caching)
304
304
  * - cache(() => [...]) - wraps children with app-level defaults
305
- * - cache('profileName') - uses a named cache profile
306
- * - cache('profileName', () => [...]) - named profile with children
307
305
  * - cache({ ttl: 60 }, () => [...]) - with explicit options
306
+ *
307
+ * Named cache profiles are applied via the `"use cache: <profile>"` directive,
308
+ * not a `cache("profileName")` form in the route tree.
308
309
  */
309
310
  const cache: RouteHelpers<any, any>["cache"] = (
310
311
  optionsOrChildren?:
311
312
  | PartialCacheOptions
312
313
  | false
313
- | string
314
314
  | (() => UseItems<AllUseItems>),
315
315
  maybeChildren?: () => UseItems<AllUseItems>,
316
316
  ) => {
@@ -326,18 +326,6 @@ const cache: RouteHelpers<any, any>["cache"] = (
326
326
  // cache() - no args, use defaults
327
327
  options = {};
328
328
  children = undefined;
329
- } else if (typeof optionsOrChildren === "string") {
330
- // cache('profileName') or cache('profileName', () => [...])
331
- // Resolve from context-scoped profiles (set per-router via HelperContext).
332
- const ctxStore = RangoContext.getStore();
333
- const profile = ctxStore?.cacheProfiles?.[optionsOrChildren];
334
- invariant(
335
- profile,
336
- `cache("${optionsOrChildren}"): unknown cache profile. ` +
337
- `Define it in createRouter({ cacheProfiles: { "${optionsOrChildren}": { ttl: ... } } }).`,
338
- );
339
- options = { ttl: profile.ttl, swr: profile.swr, tags: profile.tags };
340
- children = maybeChildren;
341
329
  } else if (typeof optionsOrChildren === "function") {
342
330
  // cache(() => [...]) - use empty options (will use defaults)
343
331
  options = {};
@@ -393,10 +381,10 @@ const cache: RouteHelpers<any, any>["cache"] = (
393
381
  return { name: namespace, type: "cache" } as CacheItem;
394
382
  }
395
383
 
396
- // Inside a loader() use() callback, only the direct form — cache()/cache(opts)/
397
- // cache("profile") — writes cache config to the loader entry. The wrapper
398
- // form creates a structural cache boundary with its own children scope, which
399
- // has no effect on the loader and would silently no-op.
384
+ // Inside a loader() use() callback, only the direct form — cache()/cache(opts)
385
+ // — writes cache config to the loader entry. The wrapper form creates a
386
+ // structural cache boundary with its own children scope, which has no effect
387
+ // on the loader and would silently no-op.
400
388
  invariant(
401
389
  !(ctx.parent && (ctx.parent as any).type === "loader"),
402
390
  "cache() wrapper form is not valid inside loader() use(). Use cache({...}) without children to configure the loader's cache.",
@@ -441,10 +441,8 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
441
441
  cache: {
442
442
  (): CacheItem;
443
443
  (children: () => UseItems<AllUseItems>): CacheItem;
444
- (profileName: string): CacheItem;
445
- (profileName: string, use: () => UseItems<AllUseItems>): CacheItem;
446
444
  (
447
- options: PartialCacheOptions | false,
445
+ options: PartialCacheOptions<TEnv> | false,
448
446
  use?: () => UseItems<AllUseItems>,
449
447
  ): CacheItem;
450
448
  };
@@ -497,6 +495,8 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
497
495
  * @param children - Optional callback returning child routes to wrap
498
496
  */
499
497
  transition: {
498
+ (): TransitionItem;
499
+ (children: () => UseItems<AllUseItems>): TransitionItem;
500
500
  (config: TransitionConfig): TransitionItem;
501
501
  (
502
502
  config: TransitionConfig,
@@ -4,6 +4,7 @@ import {
4
4
  getRequestContext,
5
5
  _getRequestContext,
6
6
  } from "../server/request-context.js";
7
+ import { markExternalRedirect } from "../redirect-origin.js";
7
8
 
8
9
  /**
9
10
  * Create a soft redirect Response for middleware short-circuit
@@ -39,6 +40,11 @@ import {
39
40
  * status: 303,
40
41
  * state: [Flash({ text: "Session expired" })],
41
42
  * });
43
+ *
44
+ * // Off-host redirect (opt out of the same-origin guard). Without
45
+ * // `external: true`, a cross-origin target is blocked and replaced with the
46
+ * // app root, matching the client's open-redirect protection.
47
+ * return redirect('https://accounts.example.com/oauth', { external: true });
42
48
  * ```
43
49
  */
44
50
  export function redirect(url: string, status?: number): Response;
@@ -47,13 +53,18 @@ export function redirect(
47
53
  options: {
48
54
  status?: number;
49
55
  state?: LocationStateEntry | LocationStateEntry[];
56
+ external?: boolean;
50
57
  },
51
58
  ): Response;
52
59
  export function redirect(
53
60
  url: string,
54
61
  statusOrOptions?:
55
62
  | number
56
- | { status?: number; state?: LocationStateEntry | LocationStateEntry[] },
63
+ | {
64
+ status?: number;
65
+ state?: LocationStateEntry | LocationStateEntry[];
66
+ external?: boolean;
67
+ },
57
68
  ): Response {
58
69
  const status =
59
70
  typeof statusOrOptions === "number"
@@ -61,6 +72,8 @@ export function redirect(
61
72
  : (statusOrOptions?.status ?? 302);
62
73
  const state =
63
74
  typeof statusOrOptions === "object" ? statusOrOptions?.state : undefined;
75
+ const external =
76
+ typeof statusOrOptions === "object" ? statusOrOptions?.external : undefined;
64
77
 
65
78
  if (state) {
66
79
  const ctx = requireRequestContext();
@@ -85,17 +98,38 @@ export function redirect(
85
98
  }
86
99
 
87
100
  // Auto-prefix root-relative URLs with basename for app-local redirects.
101
+ // Treat the URL as already-prefixed when the basename is followed by a path
102
+ // separator, a query, a fragment, or end-of-string, so "/admin?tab=x" and
103
+ // "/admin#frag" are not double-prefixed into "/admin/admin?tab=x".
88
104
  const bn = _getRequestContext()?._basename;
89
105
  let resolvedUrl = url;
90
- if (bn && url.startsWith("/") && !url.startsWith(bn + "/") && url !== bn) {
106
+ if (
107
+ bn &&
108
+ url.startsWith("/") &&
109
+ url !== bn &&
110
+ !url.startsWith(bn + "/") &&
111
+ !url.startsWith(bn + "?") &&
112
+ !url.startsWith(bn + "#")
113
+ ) {
91
114
  resolvedUrl = url === "/" ? bn : bn + url;
92
115
  }
93
116
 
94
- return new Response(null, {
95
- status,
96
- headers: {
97
- Location: resolvedUrl,
98
- "X-RSC-Redirect": "soft",
99
- },
100
- });
117
+ const headers: Record<string, string> = {
118
+ Location: resolvedUrl,
119
+ "X-RSC-Redirect": "soft",
120
+ };
121
+
122
+ const response = new Response(null, { status, headers });
123
+
124
+ // Mark an explicit off-host redirect with an out-of-band brand so the
125
+ // same-origin guard (rsc/redirect-guard.ts) lets it through. The brand is a
126
+ // WeakSet membership on this Response object -- NOT a wire header -- so the
127
+ // opt-in cannot be forged by an attacker-controlled upstream response a
128
+ // proxy-style response route might copy. The internal redirect-rebuild paths
129
+ // transfer the brand; the guard reads and clears it (see markExternalRedirect).
130
+ if (external) {
131
+ markExternalRedirect(response);
132
+ }
133
+
134
+ return response;
101
135
  }
@@ -139,6 +139,12 @@ export function mergeHandlerUse(
139
139
  mountSite: string,
140
140
  ): (() => any[]) | undefined {
141
141
  if (!handlerUse && !explicitUse) return undefined;
142
+ // Validation asymmetry (intentional, pre-1.0): only handler.use() items are
143
+ // checked against the mount-site allow-list (validateHandlerUseItems below).
144
+ // Explicit use() items pass through unvalidated on both the explicit-only
145
+ // branch here and the merged branch, so a structurally-valid-but-prohibited
146
+ // item (e.g. middleware() inside a parallel slot) is not rejected at this seam.
147
+ // Documented rather than enforced for now; revisit before 1.0 (#569).
142
148
  if (!handlerUse) return explicitUse;
143
149
  if (!explicitUse) {
144
150
  return () => {
@@ -8,15 +8,10 @@
8
8
  * See docs/manifests.md for the full data flow.
9
9
  */
10
10
 
11
- // Singleton route map instance - populated incrementally as routes are encountered
12
11
  let globalRouteMap: Record<string, string> = {};
13
12
 
14
- // Cached complete manifest - includes all routes (including lazy includes)
15
- // Set from runtime cache or build-time import
16
13
  let cachedManifest: Record<string, string> | null = null;
17
14
 
18
- // Pre-computed route entries from build-time prefix tree leaf nodes.
19
- // Used by evaluateLazyEntry() to skip running the handler for route matching.
20
15
  let cachedPrecomputedEntries: Array<{
21
16
  staticPrefix: string;
22
17
  routes: Record<string, string>;
@@ -43,7 +38,6 @@ export function registerRouteMap(map: Record<string, string>): void {
43
38
  * @internal
44
39
  */
45
40
  export function getGlobalRouteMap(): Record<string, string> {
46
- // Cached manifest is complete (includes lazy routes), so prefer it
47
41
  if (cachedManifest) {
48
42
  return cachedManifest;
49
43
  }
@@ -231,10 +225,6 @@ export function waitForManifestReady(): Promise<void> | null {
231
225
  return manifestReadyPromise;
232
226
  }
233
227
 
234
- // ============================================================================
235
- // Route Scope Registry
236
- // ============================================================================
237
-
238
228
  // Tracks whether each route is at root scope (no named include boundary above).
239
229
  // Used by dot-local reverse resolution to decide whether bare-name fallback
240
230
  // is allowed after scoped lookups are exhausted.
@@ -259,14 +249,8 @@ export function isRouteRootScoped(routeName: string): boolean | undefined {
259
249
  return rootScopeRoutes.get(routeName);
260
250
  }
261
251
 
262
- // ============================================================================
263
- // Search Schema Registry
264
- // ============================================================================
265
-
266
252
  import type { SearchSchema } from "./search-params.js";
267
253
 
268
- // Global search schema map: route name -> search schema descriptor.
269
- // Populated by path() when a search option is provided.
270
254
  const globalSearchSchemas: Map<string, SearchSchema> = new Map();
271
255
 
272
256
  export function registerSearchSchema(