@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.b3f2d0d9

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 (255) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +777 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +85 -12
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +21 -6
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +57 -0
  183. package/src/testing/flight-tree.ts +320 -0
  184. package/src/testing/flight.entry.ts +39 -0
  185. package/src/testing/flight.ts +197 -0
  186. package/src/testing/generated-routes.ts +223 -0
  187. package/src/testing/index.ts +106 -0
  188. package/src/testing/internal/context.ts +304 -0
  189. package/src/testing/internal/flight-client-globals.ts +30 -0
  190. package/src/testing/render-route.tsx +565 -0
  191. package/src/testing/run-loader.ts +341 -0
  192. package/src/testing/run-middleware.ts +179 -0
  193. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  194. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  195. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  196. package/src/testing/vitest-stubs/version.ts +5 -0
  197. package/src/testing/vitest.ts +270 -0
  198. package/src/types/global-namespace.ts +39 -26
  199. package/src/types/handler-context.ts +68 -50
  200. package/src/types/index.ts +1 -0
  201. package/src/types/loader-types.ts +5 -6
  202. package/src/types/request-scope.ts +126 -0
  203. package/src/types/route-entry.ts +11 -0
  204. package/src/types/segments.ts +35 -2
  205. package/src/urls/include-helper.ts +34 -67
  206. package/src/urls/index.ts +0 -3
  207. package/src/urls/path-helper-types.ts +41 -7
  208. package/src/urls/path-helper.ts +17 -52
  209. package/src/urls/pattern-types.ts +36 -19
  210. package/src/urls/response-types.ts +22 -29
  211. package/src/urls/type-extraction.ts +26 -116
  212. package/src/urls/urls-function.ts +1 -5
  213. package/src/use-loader.tsx +413 -42
  214. package/src/vite/debug.ts +185 -0
  215. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  216. package/src/vite/discovery/discover-routers.ts +101 -51
  217. package/src/vite/discovery/discovery-errors.ts +194 -0
  218. package/src/vite/discovery/gate-state.ts +171 -0
  219. package/src/vite/discovery/prerender-collection.ts +67 -26
  220. package/src/vite/discovery/route-types-writer.ts +40 -84
  221. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  222. package/src/vite/discovery/state.ts +33 -0
  223. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  224. package/src/vite/index.ts +2 -0
  225. package/src/vite/plugin-types.ts +67 -0
  226. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  227. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  228. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  229. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  230. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  231. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  232. package/src/vite/plugins/expose-action-id.ts +54 -30
  233. package/src/vite/plugins/expose-id-utils.ts +12 -8
  234. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  235. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  236. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  237. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  238. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  239. package/src/vite/plugins/performance-tracks.ts +29 -25
  240. package/src/vite/plugins/use-cache-transform.ts +65 -50
  241. package/src/vite/plugins/version-injector.ts +39 -23
  242. package/src/vite/plugins/version-plugin.ts +59 -2
  243. package/src/vite/plugins/virtual-entries.ts +2 -2
  244. package/src/vite/rango.ts +116 -29
  245. package/src/vite/router-discovery.ts +750 -100
  246. package/src/vite/utils/ast-handler-extract.ts +15 -15
  247. package/src/vite/utils/banner.ts +1 -1
  248. package/src/vite/utils/bundle-analysis.ts +4 -2
  249. package/src/vite/utils/client-chunks.ts +190 -0
  250. package/src/vite/utils/forward-user-plugins.ts +193 -0
  251. package/src/vite/utils/manifest-utils.ts +21 -5
  252. package/src/vite/utils/package-resolution.ts +41 -1
  253. package/src/vite/utils/prerender-utils.ts +21 -6
  254. package/src/vite/utils/shared-utils.ts +107 -26
  255. package/src/browser/action-response-classifier.ts +0 -99
@@ -56,6 +56,15 @@ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
56
56
  /** Header storing cache status: HIT | REVALIDATING */
57
57
  export const CACHE_STATUS_HEADER = "x-edge-cache-status";
58
58
 
59
+ /**
60
+ * Header stashing the route author's original Cache-Control on L1 document
61
+ * entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
62
+ * `max-age` so the CF Cache API retains the entry across the whole SWR window;
63
+ * getResponse restores this original value before serving so the client and any
64
+ * upstream CDN see the author's intended directive, not the internal edge TTL.
65
+ */
66
+ const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
67
+
59
68
  /**
60
69
  * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
61
70
  * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
@@ -67,13 +76,11 @@ export const MAX_REVALIDATION_INTERVAL = 30;
67
76
  // Types
68
77
  // ============================================================================
69
78
 
70
- /**
71
- * Cloudflare Workers ExecutionContext (subset we need)
72
- */
73
- export interface ExecutionContext {
74
- waitUntil(promise: Promise<any>): void;
75
- passThroughOnException(): void;
76
- }
79
+ // Re-exported from the canonical home so cf-cache-store consumers keep
80
+ // importing `ExecutionContext` from this module without a second interface
81
+ // drifting over time.
82
+ export type { ExecutionContext } from "../../types/request-scope.js";
83
+ import type { ExecutionContext } from "../../types/request-scope.js";
77
84
 
78
85
  /**
79
86
  * Minimal Cloudflare KV Namespace interface.
@@ -184,7 +191,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
184
191
  * Cache version string override. When this changes, all cached entries are
185
192
  * effectively invalidated (new keys won't match old entries).
186
193
  *
187
- * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
194
+ * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
188
195
  * Only set this if you need a custom versioning strategy.
189
196
  */
190
197
  version?: string;
@@ -421,7 +428,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
421
428
  }
422
429
 
423
430
  // L2: persist to KV
424
- this.kvSetSegment(key, data, staleAt, totalTtl);
431
+ this.kvSetSegment(key, data, staleAt, totalTtl, swrWindow);
425
432
  } catch (error) {
426
433
  console.error("[CFCacheStore] set failed:", error);
427
434
  }
@@ -480,7 +487,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
480
487
  const isStale = staleAt > 0 && Date.now() > staleAt;
481
488
 
482
489
  return {
483
- response,
490
+ response: this.toClientResponse(response),
484
491
  shouldRevalidate: isStale,
485
492
  };
486
493
  } catch (error) {
@@ -489,6 +496,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
489
496
  }
490
497
  }
491
498
 
499
+ /**
500
+ * Strip internal edge headers and restore the author's Cache-Control before a
501
+ * cached document Response is served to a client. L1 entries carry the
502
+ * internal staleness/status headers and a rewritten Cache-Control; none of
503
+ * those should reach the browser or an upstream CDN.
504
+ */
505
+ private toClientResponse(response: Response): Response {
506
+ const headers = new Headers(response.headers);
507
+ const originalCacheControl = headers.get(CACHE_ORIG_CC_HEADER);
508
+ if (originalCacheControl !== null) {
509
+ headers.set("Cache-Control", originalCacheControl);
510
+ } else {
511
+ headers.delete("Cache-Control");
512
+ }
513
+ headers.delete(CACHE_ORIG_CC_HEADER);
514
+ headers.delete(CACHE_STALE_AT_HEADER);
515
+ headers.delete(CACHE_STATUS_HEADER);
516
+ return new Response(response.body, {
517
+ status: response.status,
518
+ statusText: response.statusText,
519
+ headers,
520
+ });
521
+ }
522
+
492
523
  /**
493
524
  * Store a Response with TTL and optional SWR window (for document-level caching).
494
525
  * When KV is configured, also persists to L2.
@@ -515,8 +546,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
515
546
  : [null, null]
516
547
  : [response.body, null];
517
548
 
518
- // Clone and add cache headers
549
+ // Clone and add cache headers. The author's Cache-Control is stashed and
550
+ // replaced with a long max-age so the CF Cache API holds the entry across
551
+ // the SWR window; getResponse restores the original before serving.
519
552
  const headers = new Headers(response.headers);
553
+ const originalCacheControl = response.headers.get("Cache-Control");
554
+ if (originalCacheControl !== null) {
555
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
556
+ }
520
557
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
521
558
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
522
559
 
@@ -766,13 +803,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
766
803
  data: CachedEntryData,
767
804
  staleAt: number,
768
805
  totalTtl: number,
806
+ swrWindow: number,
769
807
  ): void {
770
808
  // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
771
809
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
772
810
 
773
811
  const kvKey = this.toKVKey(key);
774
- const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
775
- const expiresAt = staleAt + swrWindow;
812
+ const expiresAt = staleAt + swrWindow * 1000;
776
813
 
777
814
  this.waitUntil(async () => {
778
815
  try {
@@ -939,6 +976,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
939
976
  const request = this.keyToRequest(`doc:${key}`);
940
977
 
941
978
  const headers = new Headers(envelope.hd);
979
+ const originalCacheControl = headers.get("Cache-Control");
980
+ if (originalCacheControl !== null) {
981
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
982
+ }
942
983
  headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
943
984
  headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
944
985
 
@@ -78,6 +78,9 @@ export {
78
78
  // Re-export useHref - it's a "use client" hook
79
79
  export { useHref } from "./browser/react/use-href.js";
80
80
 
81
+ // Re-export useReverse - it's a "use client" hook
82
+ export { useReverse } from "./browser/react/use-reverse.js";
83
+
81
84
  // Re-export useHandle - it's a "use client" hook
82
85
  export { useHandle } from "./browser/react/use-handle.js";
83
86
 
package/src/client.tsx CHANGED
@@ -21,6 +21,83 @@ import {
21
21
  } from "./route-content-wrapper.js";
22
22
  import { OutletProvider } from "./outlet-provider.js";
23
23
  import { MountContextProvider } from "./browser/react/mount-context.js";
24
+ import { getMemoizedContentPromise } from "./segment-content-promise.js";
25
+
26
+ /**
27
+ * Render the content for a named parallel/intercept slot segment.
28
+ *
29
+ * Shared by Outlet (with `name` prop) and ParallelOutlet — both resolve a
30
+ * segment from context.parallel by slot name and then render it through the
31
+ * same layout/loader/mountPath wrapping pipeline.
32
+ */
33
+ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
34
+ if (!segment) return null;
35
+
36
+ const content: ReactNode =
37
+ segment.loading || segment.component instanceof Promise ? (
38
+ <RouteContentWrapper
39
+ content={getMemoizedContentPromise(segment.component)}
40
+ fallback={segment.loading}
41
+ segmentId={segment.id}
42
+ />
43
+ ) : (
44
+ (segment.component ?? null)
45
+ );
46
+
47
+ const hasOwnLoaders = !!(segment.loaderDataPromise && segment.loaderIds);
48
+ const loaderWrapped = hasOwnLoaders ? (
49
+ <LoaderBoundary
50
+ loaderDataPromise={segment.loaderDataPromise!}
51
+ loaderIds={segment.loaderIds!}
52
+ fallback={segment.loading}
53
+ outletKey={segment.id + "-loader"}
54
+ outletContent={null}
55
+ segment={segment}
56
+ >
57
+ {content}
58
+ </LoaderBoundary>
59
+ ) : null;
60
+
61
+ let result: ReactNode;
62
+ if (segment.layout) {
63
+ // Layout renders immediately; if loaders exist, the LoaderBoundary becomes
64
+ // the outlet content so layout's <Outlet /> suspends until loaders resolve.
65
+ result = (
66
+ <OutletProvider
67
+ content={hasOwnLoaders ? loaderWrapped : content}
68
+ segment={segment}
69
+ >
70
+ {segment.layout}
71
+ </OutletProvider>
72
+ );
73
+ } else if (hasOwnLoaders) {
74
+ // No layout but has loaders — wrap content with LoaderBoundary for useLoader context.
75
+ // Common for intercept routes that use useLoader without a custom layout.
76
+ result = loaderWrapped;
77
+ } else {
78
+ result = content;
79
+ }
80
+
81
+ if (segment.mountPath) {
82
+ return (
83
+ <MountContextProvider value={segment.mountPath}>
84
+ {result}
85
+ </MountContextProvider>
86
+ );
87
+ }
88
+
89
+ return result;
90
+ }
91
+
92
+ function useSlotSegment(
93
+ context: OutletContextValue | null,
94
+ name: `@${string}` | undefined,
95
+ ): ResolvedSegment | null {
96
+ return useMemo(() => {
97
+ if (!name || !context?.parallel) return null;
98
+ return context.parallel.find((seg) => seg.slot === name) ?? null;
99
+ }, [context, name]);
100
+ }
24
101
 
25
102
  /**
26
103
  * Outlet component - renders child content in layouts
@@ -61,95 +138,10 @@ import { MountContextProvider } from "./browser/react/mount-context.js";
61
138
  */
62
139
  export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
63
140
  const context = useContext(OutletContext);
141
+ const namedSegment = useSlotSegment(context, name);
64
142
 
65
- // If name provided, render parallel/intercept content for that slot
66
143
  if (name) {
67
- const segment = context?.parallel?.find((seg) => seg.slot === name) ?? null;
68
-
69
- if (!segment) return null;
70
-
71
- // Determine the content to render
72
- let content: ReactNode;
73
- if (segment.loading || segment.component instanceof Promise) {
74
- // Use RouteContentWrapper to handle Suspense wrapping properly
75
- content = (
76
- <RouteContentWrapper
77
- content={
78
- segment.component instanceof Promise
79
- ? segment.component
80
- : Promise.resolve(segment.component)
81
- }
82
- fallback={segment.loading}
83
- segmentId={segment.id}
84
- />
85
- );
86
- } else {
87
- content = segment.component ?? null;
88
- }
89
-
90
- let result: ReactNode;
91
-
92
- // If segment has a layout, wrap appropriately
93
- if (segment.layout) {
94
- // Check if this segment has loaders that need streaming
95
- // The layout renders immediately, LoaderBoundary becomes the outlet content
96
- // When layout renders <Outlet />, it gets the LoaderBoundary which suspends
97
- if (segment.loaderDataPromise && segment.loaderIds) {
98
- const loaderAwareContent = (
99
- <LoaderBoundary
100
- loaderDataPromise={segment.loaderDataPromise}
101
- loaderIds={segment.loaderIds}
102
- fallback={segment.loading}
103
- outletKey={segment.id + "-loader"}
104
- outletContent={null}
105
- segment={segment}
106
- >
107
- {content}
108
- </LoaderBoundary>
109
- );
110
-
111
- result = (
112
- <OutletProvider content={loaderAwareContent} segment={segment}>
113
- {segment.layout}
114
- </OutletProvider>
115
- );
116
- } else {
117
- // No loaders - wrap in OutletProvider so layout can use <Outlet />
118
- result = (
119
- <OutletProvider content={content} segment={segment}>
120
- {segment.layout}
121
- </OutletProvider>
122
- );
123
- }
124
- } else if (segment.loaderDataPromise && segment.loaderIds) {
125
- // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
126
- // This is common for intercept routes that use useLoader without a custom layout
127
- result = (
128
- <LoaderBoundary
129
- loaderDataPromise={segment.loaderDataPromise}
130
- loaderIds={segment.loaderIds}
131
- fallback={segment.loading}
132
- outletKey={segment.id + "-loader"}
133
- outletContent={null}
134
- segment={segment}
135
- >
136
- {content}
137
- </LoaderBoundary>
138
- );
139
- } else {
140
- result = content;
141
- }
142
-
143
- // Wrap with MountContextProvider for include() scoped parallel/intercept slots
144
- if (segment.mountPath) {
145
- return (
146
- <MountContextProvider value={segment.mountPath}>
147
- {result}
148
- </MountContextProvider>
149
- );
150
- }
151
-
152
- return result;
144
+ return renderSlotContent(namedSegment);
153
145
  }
154
146
 
155
147
  // Default: render child content
@@ -163,6 +155,7 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
163
155
 
164
156
  return content;
165
157
  }
158
+
166
159
  /**
167
160
  * ParallelOutlet component - renders content for a named parallel slot
168
161
  *
@@ -187,94 +180,9 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
187
180
  */
188
181
  export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
189
182
  const context = useContext(OutletContext);
190
- const segment = useMemo(() => {
191
- if (!context?.parallel) return null;
192
- return context.parallel.find((seg) => seg.slot === name) ?? null;
193
- }, [context, name]);
194
-
195
- if (!segment) return null;
196
-
197
- // Determine the content to render
198
- let content: ReactNode;
199
- if (segment.loading || segment.component instanceof Promise) {
200
- // Use RouteContentWrapper to handle Suspense wrapping properly
201
- content = (
202
- <RouteContentWrapper
203
- content={
204
- segment.component instanceof Promise
205
- ? segment.component
206
- : Promise.resolve(segment.component)
207
- }
208
- fallback={segment.loading}
209
- segmentId={segment.id}
210
- />
211
- );
212
- } else {
213
- content = segment.component ?? null;
214
- }
215
-
216
- let result: ReactNode;
217
-
218
- // If segment has a layout, wrap appropriately
219
- if (segment.layout) {
220
- // Check if this segment has loaders that need streaming
221
- // The layout renders immediately, LoaderBoundary becomes the outlet content
222
- if (segment.loaderDataPromise && segment.loaderIds) {
223
- const loaderAwareContent = (
224
- <LoaderBoundary
225
- loaderDataPromise={segment.loaderDataPromise}
226
- loaderIds={segment.loaderIds}
227
- fallback={segment.loading}
228
- outletKey={segment.id + "-loader"}
229
- outletContent={null}
230
- segment={segment}
231
- >
232
- {content}
233
- </LoaderBoundary>
234
- );
235
-
236
- result = (
237
- <OutletProvider content={loaderAwareContent} segment={segment}>
238
- {segment.layout}
239
- </OutletProvider>
240
- );
241
- } else {
242
- // No loaders - wrap in OutletProvider so layout can use <Outlet />
243
- result = (
244
- <OutletProvider content={content} segment={segment}>
245
- {segment.layout}
246
- </OutletProvider>
247
- );
248
- }
249
- } else if (segment.loaderDataPromise && segment.loaderIds) {
250
- // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
251
- // This is common for intercept routes that use useLoader without a custom layout
252
- result = (
253
- <LoaderBoundary
254
- loaderDataPromise={segment.loaderDataPromise}
255
- loaderIds={segment.loaderIds}
256
- fallback={segment.loading}
257
- outletKey={segment.id + "-loader"}
258
- outletContent={null}
259
- segment={segment}
260
- >
261
- {content}
262
- </LoaderBoundary>
263
- );
264
- } else {
265
- result = content;
266
- }
267
-
268
- // Wrap with MountContextProvider for include() scoped parallel/intercept slots
269
- if (segment.mountPath) {
270
- return (
271
- <MountContextProvider value={segment.mountPath}>
272
- {result}
273
- </MountContextProvider>
274
- );
275
- }
183
+ const segment = useSlotSegment(context, name);
276
184
 
277
- return result;
185
+ return renderSlotContent(segment);
278
186
  }
279
187
 
280
188
  // OutletProvider is defined in outlet-provider.tsx to break a circular
@@ -306,6 +214,7 @@ export function useOutlet(): ReactNode {
306
214
  export {
307
215
  useLoader,
308
216
  useFetchLoader,
217
+ useRefreshLoaders,
309
218
  type LoadFunction,
310
219
  type UseLoaderResult,
311
220
  type UseFetchLoaderResult,
@@ -501,13 +410,10 @@ export {
501
410
  type LocationStateOptions,
502
411
  } from "./browser/react/location-state.js";
503
412
 
504
- // Type-safe href for client-side path validation
505
- export {
506
- href,
507
- type ValidPaths,
508
- type PatternToPath,
509
- type PathResponse,
510
- } from "./href-client.js";
413
+ // Type-safe href for client-side path validation. The path and response types
414
+ // are ambient as `Rango.Path` / `Rango.PathResponse` (declared in
415
+ // href-client.ts) — no import needed.
416
+ export { href, type PatternToPath } from "./href-client.js";
511
417
 
512
418
  // Response envelope types for consuming JSON response routes
513
419
  export type { ResponseEnvelope, ResponseError } from "./urls.js";
@@ -540,8 +446,12 @@ export { MountContext } from "./browser/react/mount-context.js";
540
446
  // Mount-aware href hook - auto-prefixes paths with include() mount
541
447
  export { useHref } from "./browser/react/use-href.js";
542
448
 
449
+ // Mount-aware reverse hook - resolves dot-prefixed names against an imported
450
+ // generated routes map (from a urls() module's .gen.ts).
451
+ export { useReverse } from "./browser/react/use-reverse.js";
452
+
543
453
  // Type-safe scoped reverse function for scopedReverse<typeof patterns>()
544
- export type { ScopedReverseFunction } from "./reverse.js";
454
+ export type { ScopedReverseFunction, LocalReverseFunction } from "./reverse.js";
545
455
 
546
456
  // Loader definition type - for typing loader props in client components
547
457
  export type { LoaderDefinition } from "./types.js";
@@ -12,7 +12,7 @@
12
12
  * interface PaginationData { current: number; total: number }
13
13
  * export const Pagination = createVar<PaginationData>();
14
14
  *
15
- * // Non-cacheable var — throws if set/get inside cache() or "use cache"
15
+ * // Non-cacheable var — ctx.get(User) throws inside a cache() boundary
16
16
  * export const User = createVar<UserData>({ cache: false });
17
17
  *
18
18
  * // handler
@@ -26,7 +26,7 @@
26
26
  export interface ContextVar<T> {
27
27
  readonly __brand: "context-var";
28
28
  readonly key: symbol;
29
- /** When false, the var is non-cacheable — throws inside cache() / "use cache" */
29
+ /** When false, ctx.get(var) throws inside a cache() boundary. */
30
30
  readonly cache: boolean;
31
31
  /** Phantom field to carry the type parameter. Never set at runtime. */
32
32
  readonly __type?: T;
@@ -35,9 +35,9 @@ export interface ContextVar<T> {
35
35
  export interface ContextVarOptions {
36
36
  /**
37
37
  * When false, marks this variable as non-cacheable.
38
- * Setting or getting this var inside a cache() boundary or "use cache"
39
- * function will throw. Use for inherently request-specific data (user
40
- * sessions, auth tokens, etc.) that must never be baked into cached segments.
38
+ * Reading this var with ctx.get() inside a cache() boundary throws. Use for
39
+ * inherently request-specific data (user sessions, auth tokens, etc.) that
40
+ * must never be baked into cached segments.
41
41
  *
42
42
  * @default true
43
43
  */
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from "react";
2
+ import { isLoaderDataResult } from "./types.js";
3
+
4
+ // Shared by segment-system (server) and LoaderResolver (client) so the
5
+ // legacy/ok/error-fallback/throw decode of resolved loader values lives once.
6
+ // Last failing loader wins errorFallback; an error without a fallback throws.
7
+ export function decodeLoaderResults(
8
+ resolvedData: any[],
9
+ loaderIds: string[],
10
+ ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
11
+ const loaderData: Record<string, any> = {};
12
+ let errorFallback: ReactNode = null;
13
+
14
+ for (let i = 0; i < loaderIds.length; i++) {
15
+ const id = loaderIds[i];
16
+ const result = resolvedData[i];
17
+
18
+ if (!isLoaderDataResult(result)) {
19
+ loaderData[id] = result;
20
+ continue;
21
+ }
22
+
23
+ if (result.ok) {
24
+ loaderData[id] = result.data;
25
+ continue;
26
+ }
27
+
28
+ if (result.fallback) {
29
+ errorFallback = result.fallback;
30
+ } else {
31
+ throw new Error(result.error.message);
32
+ }
33
+ }
34
+
35
+ return { loaderData, errorFallback };
36
+ }
package/src/errors.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Custom error classes for RSC Router
2
+ * Custom error classes for Rango
3
3
  *
4
4
  * All errors include:
5
5
  * - Descriptive names for easy identification
@@ -27,6 +27,17 @@ export class RouteNotFoundError extends Error {
27
27
  }
28
28
  }
29
29
 
30
+ // name fallback covers cross-realm errors (Vite dev dupes, RSC serialization)
31
+ // where instanceof fails.
32
+ export function isRouteNotFoundError(
33
+ error: unknown,
34
+ ): error is RouteNotFoundError {
35
+ return (
36
+ error instanceof RouteNotFoundError ||
37
+ (error instanceof Error && error.name === "RouteNotFoundError")
38
+ );
39
+ }
40
+
30
41
  /**
31
42
  * Thrown when data is not found (e.g., product with ID doesn't exist)
32
43
  * Use this in handlers/loaders to trigger the nearest notFoundBoundary
@@ -109,6 +120,24 @@ export class BuildError extends Error {
109
120
  }
110
121
  }
111
122
 
123
+ /**
124
+ * Thrown when a route-definition DSL helper (route/layout/loader/cache/…) is
125
+ * called outside an active urls()/map() builder, so there is no
126
+ * AsyncLocalStorage build context to attach to. The message names the specific
127
+ * helper and how to fix it; the `cause` records the mechanical reason so the
128
+ * failure mode is identifiable (not conflated with an unrelated throw).
129
+ */
130
+ export class DslContextError extends Error {
131
+ name = "DslContextError" as const;
132
+ cause?: unknown;
133
+
134
+ constructor(message: string, options?: ErrorOptions) {
135
+ super(message);
136
+ Object.setPrototypeOf(this, DslContextError.prototype);
137
+ this.cause = options?.cause;
138
+ }
139
+ }
140
+
112
141
  /**
113
142
  * Thrown when a network request fails (server unreachable, no internet, etc.)
114
143
  * This error triggers the root error boundary with retry capability.
package/src/handle.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { missingInjectedIdError } from "./missing-id-error.js";
2
+
1
3
  /**
2
4
  * Handle definition for accumulating data across route segments.
3
5
  *
@@ -43,6 +45,11 @@ function defaultCollect<T>(segments: T[][]): T[] {
43
45
  // Used by useHandle() to recover collect when handle is deserialized from RSC prop.
44
46
  const collectRegistry = new Map<string, (segments: unknown[][]) => unknown>();
45
47
 
48
+ // Monotonic counter for runtime fallback ids (see createHandle). Module-scoped
49
+ // and deterministic, so each createHandle() call gets a stable, unique id within
50
+ // the process. Only used when no build id was injected (a bare unit test).
51
+ let runtimeHandleIdCounter = 0;
52
+
46
53
  /**
47
54
  * Look up a collect function from the registry by handle $$id.
48
55
  * Returns undefined if not registered (falls back to defaultCollect in useHandle).
@@ -93,14 +100,22 @@ export function createHandle<TData, TAccumulated = TData[]>(
93
100
  collect?: (segments: TData[][]) => TAccumulated,
94
101
  __injectedId?: string,
95
102
  ): Handle<TData, TAccumulated> {
96
- const handleId = __injectedId ?? "";
103
+ let handleId = __injectedId ?? "";
97
104
 
98
105
  if (!handleId && process.env.NODE_ENV === "development") {
99
- throw new Error(
100
- "[rsc-router] Handle is missing $$id. " +
101
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
102
- "the handle is exported with: export const MyHandle = createHandle(...)",
103
- );
106
+ throw missingInjectedIdError("Handle", "createHandle");
107
+ }
108
+
109
+ // No build-injected id. This only happens in a bare unit test — every real
110
+ // build runs the rango Vite plugin, which always injects a stable id (and the
111
+ // line above throws for a genuinely non-exported handle in dev). Assign a
112
+ // process-stable runtime id so the collect registers below and the handle is
113
+ // fully exercisable in tests (useHandle, collectHandle, renderRoute's `handles`
114
+ // seeding run the REAL collect). Provably inert in production: the fallback
115
+ // never triggers when the plugin injects the id, so server/client id
116
+ // consistency (required for RSC recovery) is unaffected.
117
+ if (!handleId) {
118
+ handleId = `__rango_runtime_handle_${runtimeHandleIdCounter++}`;
104
119
  }
105
120
 
106
121
  const collectFn =
@@ -109,12 +124,10 @@ export function createHandle<TData, TAccumulated = TData[]>(
109
124
 
110
125
  // Register collect in module-level registry so useHandle() can recover it
111
126
  // when the handle is deserialized from RSC props (toJSON strips collect).
112
- if (handleId) {
113
- collectRegistry.set(
114
- handleId,
115
- collectFn as (segments: unknown[][]) => unknown,
116
- );
117
- }
127
+ collectRegistry.set(
128
+ handleId,
129
+ collectFn as (segments: unknown[][]) => unknown,
130
+ );
118
131
 
119
132
  return {
120
133
  __brand: "handle" as const,
@@ -151,7 +164,7 @@ export function collectHandleData<TData, TAccumulated>(
151
164
  const collectFn = getCollectFn(handle.$$id);
152
165
  if (!collectFn && process.env.NODE_ENV !== "production") {
153
166
  console.warn(
154
- `[rsc-router] Handle "${handle.$$id}" has no registered collect function. ` +
167
+ `[rango] Handle "${handle.$$id}" has no registered collect function. ` +
155
168
  `Falling back to flat array. Ensure the handle module is imported so ` +
156
169
  `createHandle() runs and registers the collect function.`,
157
170
  );
package/src/host/index.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * const router = createHostRouter();
13
13
  *
14
- * router.host(['.']).map(() => import('./apps/main'));
15
- * router.host(['admin.*']).map(() => import('./apps/admin'));
14
+ * router.host(['.']).lazy(() => import('./apps/main'));
15
+ * router.host(['admin.*']).lazy(() => import('./apps/admin'));
16
16
  *
17
17
  * export default {
18
18
  * fetch(request) {