@rangojs/router 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +7 -4
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -13,7 +13,7 @@ function NavIndicator() {
13
13
 
14
14
  // State properties
15
15
  nav.state; // 'idle' | 'loading'
16
- nav.isStreaming; // boolean
16
+ nav.isStreaming; // boolean — RSC data is still streaming
17
17
  nav.location; // Current URL
18
18
  nav.pendingUrl; // Target URL during navigation (or null)
19
19
 
@@ -29,7 +29,9 @@ function IsLoading() {
29
29
 
30
30
  ### useRouter()
31
31
 
32
- Access stable router actions (never causes re-renders):
32
+ Access stable router actions. The returned object never changes identity, so
33
+ components using it do not re-render on navigation. Call its methods from
34
+ event handlers or effects:
33
35
 
34
36
  ```tsx
35
37
  "use client";
@@ -38,15 +40,41 @@ import { useRouter } from "@rangojs/router/client";
38
40
  function NavigationControls() {
39
41
  const router = useRouter();
40
42
 
41
- router.push("/products"); // Navigate (adds history entry)
42
- router.replace("/login"); // Navigate (replaces history entry)
43
- router.refresh(); // Re-fetch current route data
44
- router.prefetch("/dashboard"); // Prefetch for faster navigation
45
- router.back(); // Go back in history
46
- router.forward(); // Go forward in history
43
+ return (
44
+ <>
45
+ <button onClick={() => router.push("/products")}>Products</button>
46
+ <button onClick={() => router.replace("/login", { scroll: false })}>
47
+ Log in
48
+ </button>
49
+ <button onClick={() => router.refresh()}>Reload data</button>
50
+ <button onMouseEnter={() => router.prefetch("/dashboard")}>
51
+ Dashboard
52
+ </button>
53
+ <button onClick={() => router.back()}>Back</button>
54
+ <button onClick={() => router.forward()}>Forward</button>
55
+ </>
56
+ );
47
57
  }
48
58
  ```
49
59
 
60
+ | Method | Behaviour |
61
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
62
+ | `push(url, options?)` | Navigate and add a history entry. Returns a promise. |
63
+ | `replace(url, options?)` | Navigate and replace the current history entry. Returns a promise. |
64
+ | `refresh()` | Re-fetch the current route's server data, keeping client state. Returns a promise. |
65
+ | `prefetch(url, opts?)` | Prefetch a target. `{ key: ":source" }` scopes the entry to the source page (parity with `<Link prefetchKey>`). |
66
+ | `back()` | `history.back()`; on the first entry of the session it replaces to the app root instead of leaving the app. |
67
+ | `forward()` | `history.forward()`. |
68
+
69
+ `push` / `replace` options: `scroll` (`false` keeps the current scroll
70
+ position), `revalidate` (default `true`, see below), and `state` (location
71
+ state, see [`./state.md`](./state.md)).
72
+
73
+ Target resolution: a path starting with `/` is app-absolute and gets the
74
+ router `basename` prefixed. A relative path (`"cart"`, `"./cart"`) resolves
75
+ against the current `include()` mount, so `router.push("cart")` inside
76
+ `include("/shop", ...)` navigates to `/shop/cart`.
77
+
50
78
  #### Skipping revalidation
51
79
 
52
80
  Pass `revalidate: false` to skip the RSC server fetch for same-pathname navigations (search param or hash changes). The URL updates and all hooks re-render, but server components stay as-is.
@@ -79,7 +107,7 @@ function Breadcrumbs() {
79
107
  const { path, segmentIds, location } = useSegments();
80
108
 
81
109
  // path: ["shop", "products", "123"] (split on "/", no leading slash on any element)
82
- // segmentIds: ["L0", "L0L1", "L0L1R0"] (opaque internal short-codes, not route names)
110
+ // segmentIds: ["L0", "L0L1", "L0L1R0"] (layouts and routes only; opaque internal short-codes, not route names)
83
111
  // location: URL object
84
112
 
85
113
  return <nav>{path.join(" > ")}</nav>;
@@ -91,7 +119,8 @@ const isShopRoute = useSegments((s) => s.path[0] === "shop");
91
119
 
92
120
  ### useLinkStatus()
93
121
 
94
- Track pending state inside a Link component:
122
+ Track pending state inside a Link component. `pending` is `true` while a
123
+ navigation to this link's `to` is in flight:
95
124
 
96
125
  ```tsx
97
126
  "use client";
@@ -2,24 +2,32 @@
2
2
 
3
3
  ### Outlet / ParallelOutlet
4
4
 
5
- Render child content in layouts:
5
+ Render child content in layouts. `<Outlet />` renders the matched child
6
+ segment; `<ParallelOutlet name="@slot" />` renders a named `parallel()` slot.
7
+ Both apply the segment's `loading()` fallback as a Suspense boundary:
6
8
 
7
9
  ```tsx
8
10
  import { Outlet, ParallelOutlet } from "@rangojs/router/client";
9
11
 
10
- function DashboardLayout({ children }: { children?: React.ReactNode }) {
12
+ function DashboardLayout() {
11
13
  return (
12
14
  <div className="dashboard">
13
15
  <aside>
14
16
  <ParallelOutlet name="@sidebar" />
15
17
  </aside>
16
- <main>{children ?? <Outlet />}</main>
18
+ <main>
19
+ <Outlet />
20
+ </main>
17
21
  <ParallelOutlet name="@notifications" />
18
22
  </div>
19
23
  );
20
24
  }
21
25
  ```
22
26
 
27
+ `<Outlet name="@sidebar" />` is equivalent to `<ParallelOutlet name="@sidebar" />`;
28
+ `ParallelOutlet` requires the name and reads clearer at the call site. See
29
+ `/layout` and `/parallel` for the server side.
30
+
23
31
  ### useOutlet()
24
32
 
25
33
  Access outlet content and its client-route presentation state programmatically:
@@ -48,9 +56,8 @@ function ConditionalLayout() {
48
56
  }
49
57
  ```
50
58
 
51
- Migration from the old bare-node return is `const { content, pending } =
52
- useOutlet()`, then render `content` where you previously rendered the hook result.
53
- `<Outlet />` itself is unchanged.
59
+ Migrating from the older API where `useOutlet()` returned the node directly:
60
+ destructure `content` and render it where you rendered the hook result.
54
61
 
55
62
  `pending` is narrow. After hydration, a `clientUrls()` layout receives `true`
56
63
  while a browser-local match to a different client route beneath it is presenting
@@ -74,6 +74,16 @@ Plain state can also be evaluated just in time (also requires a client component
74
74
  </Link>
75
75
  ```
76
76
 
77
+ Read plain (untyped) state by calling the hook with a type argument and no
78
+ definition:
79
+
80
+ ```tsx
81
+ const state = useLocationState<{ from?: string }>(); // { from?: string } | undefined
82
+ ```
83
+
84
+ The same `state` option exists on `router.push()` / `router.replace()` (see
85
+ [`./navigation.md`](./navigation.md)).
86
+
77
87
  ### Flash State (read-once)
78
88
 
79
89
  Create a location state with `{ flash: true }` for read-once state that
@@ -106,22 +116,22 @@ function FlashBanner() {
106
116
  ```
107
117
 
108
118
  Flash behavior is determined by the definition (`{ flash: true }`), not by which
109
- hook reads it. `useLocationState` reads the value synchronously during render,
110
- then clears it from `history.state` via `replaceState` in a `useEffect`.
111
- Multiple components reading the same flash definition all see the value.
112
- Pressing back/forward will not re-show the flash since it was cleared.
119
+ hook reads it. `useLocationState` reads the value during render (on the
120
+ hydration render it returns `undefined` and reads in a post-mount effect, so
121
+ SSR and hydration agree), then clears it from `history.state` via
122
+ `replaceState` in a `useEffect`. Multiple components reading the same flash
123
+ definition all see the value. Pressing back/forward will not re-show the flash
124
+ since it was cleared.
113
125
 
114
- Set flash state from the server via `redirect()` with state:
126
+ Set flash state from the server via `redirect()` with state (from a handler,
127
+ middleware, or server action). Import the definition from the shared module so
128
+ the client reader uses the same one:
115
129
 
116
130
  ```tsx
117
131
  // In a route handler
118
- import { redirect, createLocationState } from "@rangojs/router";
119
-
120
- export const FlashMessage = createLocationState<{ text: string }>({
121
- flash: true,
122
- });
132
+ import { redirect } from "@rangojs/router";
133
+ import { FlashMessage } from "../location-states";
123
134
 
124
- // Handler
125
135
  (ctx) => {
126
136
  return redirect("/dashboard", {
127
137
  state: [FlashMessage({ text: "Item saved!" })],
@@ -129,7 +139,7 @@ export const FlashMessage = createLocationState<{ text: string }>({
129
139
  };
130
140
  ```
131
141
 
132
- Or via `ctx.setLocationState()` on any response:
142
+ Or via `ctx.setLocationState()` on any response (handlers and middleware):
133
143
 
134
144
  ```tsx
135
145
  (ctx) => {
@@ -194,9 +204,21 @@ ProductState.delete();
194
204
  Force the client's caches to miss after a mutation the router can't see (a REST
195
205
  call, a WebSocket push, a login). It is a plain function, not a hook, so it works
196
206
  from module-level callbacks too. Imported from the root entry `@rangojs/router`,
197
- it is selected by export conditions: in a client component it marks the caches
198
- stale immediately; from a handler/server component it writes a rotated
199
- `Set-Cookie` for the responding client.
207
+ it is selected by export conditions:
208
+
209
+ - **Client** (client component or module): marks the history cache stale,
210
+ flushes the prefetch cache, rotates the router state, and notifies other tabs
211
+ — the same thing a completed server action does. During SSR of a client
212
+ component it is a no-op (dev warning).
213
+ - **Server** (handler, server component, middleware, loader, or action): writes
214
+ a rotated state `Set-Cookie` for the responding client, which marks its
215
+ caches stale when it next reads them. Idempotent within a request; throws
216
+ inside a `cache()` / `"use cache"` boundary; a no-op with a dev warning
217
+ outside a request.
218
+
219
+ Server actions already invalidate automatically; to suppress that for a no-op
220
+ action, call `keepClientCache()` inside it (see `/server-actions` → "Client
221
+ Cache After an Action").
200
222
 
201
223
  ```tsx
202
224
  "use client";
@@ -29,7 +29,7 @@ function ProductId() {
29
29
  }
30
30
  ```
31
31
 
32
- Returns merged params from all matched route segments as a `Readonly<T>` map. Updates on navigation commit (not during pending navigation).
32
+ Returns merged params from all matched route segments as a `Readonly<T>` map (default `Record<string, string | undefined>` — absent optional params are omitted, so guard them). Updates on navigation commit, not during a pending navigation — except inside an optimistically rendered `clientUrls()` destination, where it reports that destination's params (see `/client-urls`).
33
33
 
34
34
  ### usePathname()
35
35
 
@@ -119,19 +119,21 @@ import { useHref, href, Link } from "@rangojs/router/client";
119
119
 
120
120
  // Inside include("/shop", shopPatterns)
121
121
  function ShopNav() {
122
- const href = useHref();
122
+ const localHref = useHref();
123
123
 
124
124
  return (
125
125
  <>
126
126
  {/* Local paths - auto-prefixed with /shop */}
127
- <Link to={href("/cart")}>Cart</Link>
128
- <Link to={href("/product/widget")}>Widget</Link>
127
+ <Link to={localHref("/cart")}>Cart</Link>
128
+ <Link to={localHref("/product/widget")}>Widget</Link>
129
+ {/* Absolute path - not prefixed */}
130
+ <Link to={href("/about")}>About</Link>
129
131
  </>
130
132
  );
131
133
  }
132
134
  ```
133
135
 
134
- Use `useHref()` for local navigation. Use the bare `href()` function for absolute paths.
136
+ Use `useHref()` for local navigation. Use the bare `href()` function for absolute paths. The function `useHref()` returns is referentially stable within a mount, so it is safe as a hook dependency or memoized prop.
135
137
 
136
138
  ### useMount()
137
139
 
@@ -8,6 +8,12 @@ argument-hint:
8
8
 
9
9
  Route requests to different apps based on domain, subdomain, or path prefix patterns. Supports middleware, lazy loading, cookie-based host override for dev, and a fallback handler.
10
10
 
11
+ Use it when one deployment serves several independently built Rango apps (or
12
+ plain request handlers) and the hostname or a path prefix decides which one
13
+ handles the request. Each sub-app is an ordinary `createRouter()` app; the host
14
+ router only picks which one runs. For a single app mounted under a sub-path,
15
+ use `basename` instead (`/router-setup`).
16
+
11
17
  ## Import
12
18
 
13
19
  ```typescript
@@ -64,6 +70,10 @@ import { router } from "./router.js";
64
70
  export default (request: Request, input: any) => router.fetch(request, input);
65
71
  ```
66
72
 
73
+ Each sub-app has its own generated route map and `Rango.Env`; typecheck each
74
+ in its own TypeScript program (or give route names per-app prefixes) so the
75
+ global augmentations don't collide — see `/typesafety` (multi-project setup).
76
+
67
77
  Selecting the host entry — a host app has several `createRouter()` sub-apps, so single-router auto-discovery can't pick one. Either let rango auto-detect the lone `createHostRouter()` file, or point at it explicitly:
68
78
 
69
79
  ```typescript
@@ -71,7 +81,7 @@ Selecting the host entry — a host app has several `createRouter()` sub-apps, s
71
81
  rango({ preset: "vercel", hostRouter: "./src/worker.rsc.tsx" });
72
82
  ```
73
83
 
74
- On Vercel this is a single function running `hostRouter.match()` for every request (mirrors the Cloudflare single-worker model); `{ env, ctx }` (`process.env` + `{ waitUntil }`) is threaded unchanged to each matched sub-app's handler and `cache(env, ctx)` factory. See the `vercel` skill.
84
+ On Vercel this is a single function running `hostRouter.match()` for every request (mirrors the Cloudflare single-worker model); `{ env, ctx }` (`process.env` + `{ waitUntil }`) is threaded unchanged to each matched sub-app's handler and `cache(env, ctx)` factory. See `/vercel`.
75
85
 
76
86
  Unmatched hosts on node/vercel: because rango owns the generated entry (you have no worker `try/catch`), it catches `NoRouteMatchError` and returns **404** by default — so you do **not** need a catch-all host route. If you want different behavior (a branded 404, a redirect, a default app), register a catch-all mount as the **last** route, e.g. `host(["**"]).lazy(() => import("./apps/site/handler.js"))` — it matches any host, so the built-in 404 only fires when nothing matched at all. (Note `fallback()` is for cookie-override errors, not general unmatched hosts.)
77
87
 
@@ -120,6 +130,18 @@ Why two methods instead of one overloaded `.map()`:
120
130
 
121
131
  Patterns are tested in registration order. First match wins.
122
132
 
133
+ Matching details:
134
+
135
+ - **Apex means exactly two labels.** There is no Public Suffix List:
136
+ `example.co.uk` has three labels, so `.`/`*` do not match it and `*.` does.
137
+ `localhost` has one label, so it matches neither `.` nor `*.`; use an exact
138
+ `localhost` pattern (or `**`) for local dev.
139
+ - **Hosts are case-insensitive; paths are not.** A path prefix matches the
140
+ exact path or anything below it (`example.com/api` matches `"/api"` and
141
+ `"/api/x"`, not `"/apix"`). A trailing `/` on the pattern is ignored.
142
+ - A pattern that is empty, not a string, or contains whitespace throws
143
+ `InvalidPatternError` at registration.
144
+
123
145
  ## `defineHosts` for Type Safety
124
146
 
125
147
  ```typescript
@@ -142,6 +164,8 @@ Returns a frozen object — keys are autocompleted by TypeScript.
142
164
  Global middleware runs for every matched route. Per-route middleware runs only for that host pattern.
143
165
 
144
166
  ```typescript
167
+ import { createHostRouter, type Middleware } from "@rangojs/router/host";
168
+
145
169
  const router = createHostRouter();
146
170
 
147
171
  // Global — runs for all routes
@@ -150,7 +174,14 @@ router.use(async (request, input, next) => {
150
174
  return next();
151
175
  });
152
176
 
153
- // Per-route
177
+ // Per-route: return a Response without calling next() to short-circuit
178
+ const requireAuth: Middleware = async (request, input, next) => {
179
+ if (!request.headers.get("authorization")) {
180
+ return new Response("Unauthorized", { status: 401 });
181
+ }
182
+ return next();
183
+ };
184
+
154
185
  router
155
186
  .host(["admin.*"])
156
187
  .use(requireAuth)
@@ -159,23 +190,37 @@ router
159
190
 
160
191
  Middleware signature: `(request: Request, input: RouterRequestInput, next: () => Promise<Response>) => Promise<Response>`
161
192
 
193
+ Global middleware runs first, then the matched route's middleware, then the
194
+ handler. `input` is typed `RouterRequestInput<any>` on purpose: one host router
195
+ fans out to sub-apps with different env shapes, so `input.env`/`input.vars` are
196
+ `any` here and each sub-app types its own env via `createRouter<TEnv>()`.
197
+
162
198
  Calling `next()` more than once throws.
163
199
 
164
200
  ## Fallback Handler
165
201
 
166
- Handles cookie-override errors when `hostOverride` is configured (e.g., override from a disallowed host, invalid cookie hostname). The fallback does **not** catch unmatched hosts — those throw `NoRouteMatchError`. Catch that at the worker level if you need a 404.
202
+ Handles cookie-override errors when `hostOverride` is configured (e.g., override from a disallowed host, invalid cookie hostname, a throwing `validate`). The fallback does **not** catch unmatched hosts — those throw `NoRouteMatchError`. Catch that at the worker level if you need a 404.
167
203
 
168
204
  ```typescript
169
205
  const router = createHostRouter({
170
206
  hostOverride: { cookieName: "x-dev-host", allowedHosts: ["localhost"] },
171
207
  });
172
208
 
173
- // Called when cookie override fails (not for general unmatched hosts)
174
- router.fallback().map((request) => {
175
- return new Response("Invalid host override", { status: 400 });
209
+ // Called when cookie override fails (not for general unmatched hosts).
210
+ // Global middleware and the fallback's own middleware still run.
211
+ router.fallback().map((request, input) => {
212
+ // The override error is passed on the input (not part of RouterRequestInput's type)
213
+ const { error } = input as { error?: Error };
214
+ return new Response(`Invalid host override: ${error?.message}`, {
215
+ status: 400,
216
+ });
176
217
  });
177
218
  ```
178
219
 
220
+ Without a registered fallback, a failed override returns a `400` JSON
221
+ response (`{ error, message }`) that also clears the override cookie
222
+ (`Set-Cookie: <cookieName>=; Max-Age=0`).
223
+
179
224
  For unmatched hosts without `hostOverride`, catch `NoRouteMatchError` in your worker fetch. Use the `isNoRouteMatchError()` guard rather than a bare `instanceof`: a workspace with a duplicated `@rangojs/router` copy can throw the error with a different class identity, and `instanceof` would then turn the 404 into an opaque 500.
180
225
 
181
226
  ```typescript
@@ -215,10 +260,14 @@ const router = createHostRouter({
215
260
  When a request arrives:
216
261
 
217
262
  1. If no cookie → use actual hostname
218
- 2. If cookie present and host is in `allowedHosts` → use cookie value as hostname
219
- 3. If cookie present but host not allowed → throw `HostOverrideNotAllowedError`
263
+ 2. If cookie present and the actual host matches an `allowedHosts` pattern → use the cookie value as the hostname (after `validate`, when given)
264
+ 3. If cookie present but the host is not allowed → `HostOverrideNotAllowedError`, handled by the fallback (see above)
220
265
 
221
- Without a custom `validate`, the cookie value is validated as a hostname via `new URL()`.
266
+ Without a custom `validate`, the cookie value must parse as a bare hostname via
267
+ `new URL()` (no port or path) and is lowercased; otherwise
268
+ `InvalidHostnameError`. A custom `validate` returns the effective hostname; if
269
+ it throws, the error is wrapped in `HostValidationError`. The effective
270
+ hostname is then matched against the host patterns as usual.
222
271
 
223
272
  ## Debug Mode
224
273
 
@@ -251,12 +300,15 @@ matchesHost(
251
300
  new Request("https://foo.workers.dev/admin"),
252
301
  ); // true
253
302
 
254
- // Create requests for integration tests
303
+ // Create requests for integration tests (method defaults to GET, path to "/")
255
304
  const request = createTestRequest({
256
305
  host: "admin.example.com",
257
306
  path: "/dashboard",
307
+ method: "POST",
308
+ headers: { authorization: "Bearer test" },
258
309
  cookies: { "x-dev-host": "api.example.com" },
259
310
  });
311
+ const response = await router.match(request);
260
312
 
261
313
  // Test which route would match (without executing)
262
314
  router.test("admin.example.com"); // { pattern, handler, kind } | null
@@ -309,8 +361,8 @@ document navigation**, not a soft in-tree swap. When the server sees a partial
309
361
  Why a reload rather than a soft swap: a soft swap can't faithfully re-establish
310
362
  the target app's **document-level** state. Stylesheets shared across apps are
311
363
  dropped by React 19's by-`href` resource dedup; and theme, warmup, and
312
- prefetch-TTL are document-lifetime (captured once at load — see
313
- `browser/app-shell.ts`), so the target app's config would never take effect. A
364
+ prefetch-TTL are document-lifetime (captured once at document load), so the
365
+ target app's config would never take effect. A
314
366
  full document load re-establishes the target app's entire document — CSS, theme,
315
367
  meta, everything — by construction. So you do **not** need to coordinate
316
368
  stylesheet `href`s, `precedence`, theme config, etc. across independently-authored
@@ -32,6 +32,12 @@ import { menuRoutes } from "./menu";
32
32
  export const urlpatterns = urls(({ include }) => [
33
33
  include("/:locale?", menuRoutes, { name: "menu" }),
34
34
  ]);
35
+
36
+ // menu.tsx
37
+ export const menuRoutes = urls(({ path }) => [
38
+ path("/", MenuIndex, { name: "index" }),
39
+ path("/c/:slug", MenuCategory, { name: "category" }),
40
+ ]);
35
41
  ```
36
42
 
37
43
  URLs that match:
@@ -59,7 +65,8 @@ URLs that match:
59
65
  Absent optionals are `undefined` (not `""`), so `??` coalesces correctly:
60
66
 
61
67
  ```typescript
62
- import { Handler } from "@rangojs/router";
68
+ import type { Handler } from "@rangojs/router";
69
+ import { resolveLocale } from "../lib/locale";
63
70
 
64
71
  export const MenuIndex: Handler<"menu.index"> = (ctx) => {
65
72
  // ctx.params.locale is `string | undefined`
@@ -87,9 +94,9 @@ import { cookies, headers } from "@rangojs/router";
87
94
 
88
95
  export const SUPPORTED_LOCALES = ["en", "gb", "fr"] as const;
89
96
  export type Locale = (typeof SUPPORTED_LOCALES)[number];
90
- const DEFAULT_LOCALE: Locale = "en";
97
+ export const DEFAULT_LOCALE: Locale = "en";
91
98
 
92
- const isSupported = (v: string): v is Locale =>
99
+ export const isSupported = (v: string): v is Locale =>
93
100
  (SUPPORTED_LOCALES as readonly string[]).includes(v);
94
101
 
95
102
  export function resolveLocale(ctx: {
@@ -104,24 +111,35 @@ export function resolveLocale(ctx: {
104
111
  const accept = headers().get("accept-language") ?? "";
105
112
  for (const tag of accept.split(",")) {
106
113
  const code = tag.split(";")[0].trim().split("-")[0];
107
- if (isSupported(code)) return code as Locale;
114
+ if (isSupported(code)) return code;
108
115
  }
109
116
  return DEFAULT_LOCALE;
110
117
  }
111
118
  ```
112
119
 
120
+ `cookies()` and `headers()` read the current request, so the helper works in
121
+ handlers, layouts, loaders, and middleware without passing the request in.
122
+
113
123
  If you want to redirect to the canonical URL when the resolved locale
114
124
  doesn't match the URL (e.g., user has `gb` cookie but visits `/`), do
115
- that in a global middleware so it covers actions too:
125
+ that in global middleware (`router.use()`). Global middleware runs before
126
+ route matching, and its `ctx.params` come from its own pattern — not from
127
+ `include("/:locale?")` — so read the locale segment from the pathname:
116
128
 
117
129
  ```typescript
130
+ // router.tsx
118
131
  import { redirect } from "@rangojs/router";
119
-
120
- router.use("/*", async (ctx, next) => {
121
- const fromUrl = ctx.params.locale;
122
- const resolved = resolveLocale(ctx);
123
- if (resolved !== DEFAULT_LOCALE && !fromUrl) {
124
- return redirect(`/${resolved}${ctx.url.pathname}`);
132
+ import { DEFAULT_LOCALE, isSupported, resolveLocale } from "./lib/locale";
133
+
134
+ router.use(async (ctx, next) => {
135
+ if (ctx.request.method !== "GET") return next(); // leave actions alone
136
+ const first = ctx.url.pathname.split("/")[1] ?? "";
137
+ if (!isSupported(first)) {
138
+ const resolved = resolveLocale({ params: {} }); // cookie → Accept-Language → default
139
+ if (resolved !== DEFAULT_LOCALE) {
140
+ const rest = ctx.url.pathname === "/" ? "" : ctx.url.pathname;
141
+ return redirect(`/${resolved}${rest}${ctx.url.search}`);
142
+ }
125
143
  }
126
144
  await next();
127
145
  });
@@ -138,9 +156,16 @@ ctx.reverse("menu.index", { locale: "" }); // → "/"
138
156
  ctx.reverse("menu.index", { locale: undefined }); // → "/"
139
157
  ctx.reverse("menu.index", { locale: "en" }); // → "/en"
140
158
  ctx.reverse("menu.category", { locale: "en", slug: "breads" }); // → "/en/c/breads"
141
- ctx.reverse("menu.category", { slug: "breads" }); // → "/c/breads"
159
+ ctx.reverse("menu.category", { slug: "breads" }); // → "/c/breads" on an unprefixed request
142
160
  ```
143
161
 
162
+ `ctx.reverse()` auto-fills missing params from the current request, so the
163
+ last call returns `/en/c/breads` when the current URL is `/en/...`. That is
164
+ what you want for in-locale links; to leave the locale, pass it explicitly
165
+ (`{ locale: "fr" }`, or `undefined`/`""` for the bare default). Middleware and
166
+ response routes are the exception: their `ctx.reverse` fills nothing from the
167
+ request, so pass `locale` there explicitly.
168
+
144
169
  If the active locale is the app default and your URL strategy hides it
145
170
  (`"en"` → `/`, others → `/<locale>`), normalize before calling reverse:
146
171
 
@@ -177,28 +202,27 @@ export async function loadMessages(locale: Locale) {
177
202
 
178
203
  ### Server layout: hand off to the client provider
179
204
 
205
+ A layout handler receives the handler context (not props) and renders its
206
+ children with `<Outlet />`. It sees the matched route's params, including
207
+ `locale` from the include below it:
208
+
180
209
  ```tsx
181
- // layouts/intl-layout.tsx (server component)
182
- import type { ReactNode } from "react";
210
+ // layouts/intl-layout.tsx (server)
211
+ import type { Handler } from "@rangojs/router";
212
+ import { Outlet } from "@rangojs/router/client";
183
213
  import { resolveLocale } from "../lib/locale";
184
214
  import { loadMessages } from "../lib/messages";
185
215
  import { IntlClientProvider } from "../components/intl-client-provider";
186
216
 
187
- export async function IntlLayout({
188
- ctx,
189
- children,
190
- }: {
191
- ctx: any;
192
- children: ReactNode;
193
- }) {
217
+ export const IntlLayout: Handler = async (ctx) => {
194
218
  const locale = resolveLocale(ctx);
195
219
  const messages = await loadMessages(locale);
196
220
  return (
197
221
  <IntlClientProvider locale={locale} messages={messages}>
198
- {children}
222
+ <Outlet />
199
223
  </IntlClientProvider>
200
224
  );
201
- }
225
+ };
202
226
  ```
203
227
 
204
228
  ### Client provider
@@ -252,20 +276,22 @@ export const urlpatterns = urls(({ layout, include }) => [
252
276
  ```
253
277
 
254
278
  `<FormattedMessage>`, `useIntl()`, etc. work in any client component
255
- under the layout. Server components can use `formatjs`'s `createIntl()`
256
- directly with the same `messages` map for static text.
279
+ under the layout. Server components and handlers can't use React context
280
+ providers from the client; call `createIntl({ locale, messages })` (from
281
+ `@formatjs/intl` or `react-intl`) directly with the same `messages` map for
282
+ server-rendered text.
257
283
 
258
284
  ## Common Pitfalls
259
285
 
260
- | Pitfall | Fix |
261
- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
262
- | `ctx.params.locale === ""` returns `false` | Absent optionals are `undefined`, not `""`. Use `=== undefined` or `??`. |
263
- | `ctx.params.locale ?? "en"` returns `""` | Pre-fix behavior. After the include-prefix fix this works correctly. |
264
- | Bare `/` 404s when mounted via `include("/:locale?", routes)` | Requires the all-optional pattern fix in `compilePattern` (shipped). |
265
- | Unknown locale (e.g. `/de`) matches as `locale: "de"` | Add a constraint: `:locale(en\|gb\|fr)?`. Unknown values now 404. |
266
- | Reverse produces `//c/breads` for absent locale | `reverse()` collapses `undefined`/`""` segments — should not happen. File a bug. |
267
- | Locale switcher loses search params | Read `ctx.url.search` and pass to `reverse(..., undefined, parsedSearch)`. |
268
- | Action middleware can't read `ctx.params.locale` | Route middleware doesn't wrap action execution. Use global `router.use()` for actions. |
286
+ | Pitfall | Fix |
287
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
288
+ | `ctx.params.locale === ""` returns `false` | Absent optionals are `undefined`, not `""`. Use `=== undefined` or `??`. |
289
+ | Unknown locale (e.g. `/de`) matches as `locale: "de"` | Add a constraint: `:locale(en\|gb\|fr)?`. Unknown values now 404. |
290
+ | Link meant for the default locale keeps `/en` | `ctx.reverse()` auto-fills the current `locale`. Pass `{ locale: undefined }` (or `""`) to drop it. |
291
+ | Reverse produces `//c/breads` for absent locale | `reverse()` collapses `undefined`/`""` segments — should not happen. File a bug. |
292
+ | Locale switcher loses search params | Append `ctx.url.search` to the reversed URL, or pass `Object.fromEntries(ctx.searchParams)` as the third argument. |
293
+ | Global middleware's `ctx.params.locale` is always `undefined` | Global middleware params come from its own pattern. Parse the first path segment (see "Locale Resolution"). |
294
+ | Route middleware doesn't see actions | Route middleware doesn't wrap action execution. Use global `router.use()` for action-time locale logic. |
269
295
 
270
296
  ## Cross-references
271
297