@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
@@ -8,7 +8,7 @@ argument-hint: [layout|route|parallel|intercept]
8
8
 
9
9
  `transition()` opts a route (or group of routes) into transition-driven navigation. It does two things, and you choose how far to go:
10
10
 
11
- 1. **`startTransition` (the foundation).** The navigation commit is driven through React's `startTransition`. That holds the previous content across a same-route navigation (stale-while-revalidate — no loading-skeleton flash) and is the **precondition** for any view-transition animation. Works on **all** React versions. While the hold lasts, `useLoader` readers of the loaders being re-run report `isLoading: true` — render your stale indicator from it (see the hooks skill, `data.md`).
11
+ 1. **`startTransition` (the foundation).** The navigation commit is driven through React's `startTransition`. That holds the previous content across a same-route navigation (stale-while-revalidate — no loading-skeleton flash) and is the **precondition** for any view-transition animation. Works on **all** React versions. While the hold lasts, `useLoader` readers of the loaders being re-run report `isLoading: true` — keyed by loader family (`$$id`), not by segment; see the hooks skill, `data.md`.
12
12
  2. **`<ViewTransition>` (the animation, layered on top).** On React 19.3+ (or an experimental build), rango also wraps the segment content in React's `<ViewTransition>` so the swap cross-fades/morphs. This is the only part that needs 19.3+; pass `viewTransition: false` to keep #1 without it (and place your own `<ViewTransition>` where you want it).
13
13
 
14
14
  > The `<ViewTransition>` layer requires a React that exports `<ViewTransition>` / `addTransitionType`: stable 19.3+ or an experimental build. Rango feature-detects it; on React 19.2 that layer is a no-op — but the `startTransition` driving (content hold) still applies.
@@ -52,7 +52,7 @@ A simple cross-fade between pages that share a layout:
52
52
  import { urls } from "@rangojs/router";
53
53
  import { Outlet } from "@rangojs/router/client";
54
54
 
55
- function ShopShell({ children }: { children: React.ReactNode }) {
55
+ function ShopShell() {
56
56
  return (
57
57
  <div className="shop">
58
58
  <NavBar />
@@ -74,23 +74,34 @@ export const urlpatterns = urls(({ layout, path, transition }) => [
74
74
  ]);
75
75
  ```
76
76
 
77
+ The config values are React `<ViewTransition>` classes: React applies them as
78
+ `view-transition-class`, so the CSS targets the view-transition
79
+ pseudo-elements by class:
80
+
77
81
  ```css
78
- ::view-transition-old(root) {
79
- animation: fade-out 200ms ease both;
82
+ ::view-transition-old(.page-fade) {
83
+ animation: 200ms ease both fade-out;
84
+ }
85
+ ::view-transition-new(.page-fade) {
86
+ animation: 200ms ease both fade-in;
80
87
  }
81
- ::view-transition-new(root) {
82
- animation: fade-in 200ms ease both;
88
+ @keyframes fade-out {
89
+ to {
90
+ opacity: 0;
91
+ }
83
92
  }
84
- .page-fade {
85
- /* class hooks per phase */
93
+ @keyframes fade-in {
94
+ from {
95
+ opacity: 0;
96
+ }
86
97
  }
87
98
  ```
88
99
 
89
- Navigating between `/`, `/about`, and `/contact` morphs the `<Outlet />` content with the `page-fade` class. The shell (NavBar, Footer) does not morph because the wrap sits inside the shell, not around it.
100
+ Navigating between `/`, `/about`, and `/contact` cross-fades the `<Outlet />` content with the `page-fade` class. The shell (NavBar, Footer) does not animate because the wrap sits inside the shell, not around it.
90
101
 
91
102
  ## Direction-aware transitions
92
103
 
93
- `ViewTransitionClass` accepts an object form keyed by transition type. Rango tags forward navigations as `"navigation"` and back/forward popstate as `"navigation-back"`:
104
+ `ViewTransitionClass` accepts an object form keyed by transition type. Rango tags forward navigations as `"navigation"` and back/forward navigations restored from the client cache as `"navigation-back"` (a back/forward that has to refetch from the server is tagged `"navigation"`):
94
105
 
95
106
  ```tsx
96
107
  layout(<ShopShell />, () => [
@@ -106,12 +117,19 @@ layout(<ShopShell />, () => [
106
117
  ```
107
118
 
108
119
  ```css
109
- .slide-left {
110
- animation-name: slide-from-right;
120
+ ::view-transition-old(.slide-left) {
121
+ animation: 250ms ease both slide-out-to-left;
122
+ }
123
+ ::view-transition-new(.slide-left) {
124
+ animation: 250ms ease both slide-in-from-right;
111
125
  }
112
- .slide-right {
113
- animation-name: slide-from-left;
126
+ ::view-transition-old(.slide-right) {
127
+ animation: 250ms ease both slide-out-to-right;
114
128
  }
129
+ ::view-transition-new(.slide-right) {
130
+ animation: 250ms ease both slide-in-from-left;
131
+ }
132
+ /* @keyframes slide-out-to-left, slide-in-from-right, ... defined as usual */
115
133
  ```
116
134
 
117
135
  > Note: `"action"` is only tagged on partial-update action/refetch paths today; ordinary `server-action-bridge` commits (`useAction` / `useActionState` revalidations) are not currently tagged. Don't rely on an `action`-keyed class to fire on every form action.
@@ -147,8 +165,8 @@ function GalleryShell() {
147
165
  <main>
148
166
  <Outlet /> {/* page transition lands here */}
149
167
  </main>
150
- <ParallelOutlet name="@modal" />{" "}
151
- {/* modal mounts here — sibling of the VT */}
168
+ {/* modal mounts here, as a sibling of the VT */}
169
+ <ParallelOutlet name="@modal" />
152
170
  </>
153
171
  );
154
172
  }
@@ -222,7 +240,7 @@ interface TransitionConfig {
222
240
 
223
241
  ## Conditional transitions (`when`)
224
242
 
225
- `transition({ when })` gates the hold per request. The predicate runs server-side and outside any cache scope; return `false` to drop this segment's transition for the request (the navigation streams its `loading()` fallback instead of holding).
243
+ `transition({ when })` gates the hold per request. The predicate runs server-side and outside any cache scope; return `false` to drop this segment's transition for the request. The hold is navigation-wide: the navigation streams its `loading()` fallback instead of holding only when no other matched segment still has a transition (the common case is a single `transition()` on the route).
226
244
 
227
245
  Timing follows the route's rendering contract:
228
246
 
@@ -280,7 +298,10 @@ urls(({ path, transition }) => [
280
298
  ]),
281
299
  ]);
282
300
 
283
- // ProductPage renders the boundary itself, exactly where it's wanted:
301
+ // ProductPage renders the boundary itself, exactly where it's wanted
302
+ // (ViewTransition is exported by React 19.3+ and experimental builds):
303
+ import { ViewTransition } from "react";
304
+
284
305
  function ProductPage() {
285
306
  return (
286
307
  <ViewTransition name="hero">
@@ -311,7 +332,7 @@ const router = createRouter<AppEnv>({ viewTransition: false });
311
332
  | `transition({ viewTransition: false })` | `"auto"` | no wrap (per-route wins) | **ST only** |
312
333
  | `transition({ viewTransition: false })` | `false` | no wrap | ST only |
313
334
 
314
- On stable React the "VT" column is always a no-op (there is no `<ViewTransition>`), so every row collapses to its `startTransition`-only behavior there.
335
+ On React 19.2 (no `<ViewTransition>` export) the "VT" column is a no-op, so every row collapses to its `startTransition`-only behavior there.
315
336
 
316
337
  | Config | Router boundary | startTransition driving (no skeleton flash) | Your own `<ViewTransition name>` |
317
338
  | ---------------------------------------------------- | ---------------- | ------------------------------------------- | ---------------------------------- |
@@ -320,7 +341,7 @@ On stable React the "VT" column is always a no-op (there is no `<ViewTransition>
320
341
  | `transition({ viewTransition: false })` | none | yes | fires alone |
321
342
  | global `viewTransition: false`, route `transition()` | none | yes | fires alone |
322
343
 
323
- > On **stable** React there is no `<ViewTransition>` at all, so `viewTransition: false` is visually a no-op there — but the startTransition driving and content-hold still apply, identical to `transition({})`.
344
+ > On React 19.2 there is no `<ViewTransition>` at all, so `viewTransition: false` is visually a no-op there — but the startTransition driving and content-hold still apply, identical to `transition({})`.
324
345
 
325
346
  ## Testing a transition() route
326
347
 
@@ -6,7 +6,13 @@
6
6
  * turns these into tsc errors. Run via tsconfig.augment-check.json.
7
7
  */
8
8
  import "./augment.js";
9
- import type { Handler, RouteParams, RouteSearchParams } from "../index.js";
9
+ import type {
10
+ Handler,
11
+ MiddlewareContext,
12
+ ResponseHandlerContext,
13
+ RouteParams,
14
+ RouteSearchParams,
15
+ } from "../index.js";
10
16
  import type { DefaultRouteName } from "../types/global-namespace.js";
11
17
  import { href } from "../href-client.js";
12
18
  import type { Money, TestBindings } from "./augment.js";
@@ -39,6 +45,25 @@ const varsHandler: Handler<"home"> = (ctx) => {
39
45
  };
40
46
  void varsHandler;
41
47
 
48
+ // Response-route and middleware ctx.reverse resolve global names only (no
49
+ // include() scope): generated names and params are validated, `.name` is not.
50
+ function responseReverse(ctx: ResponseHandlerContext): void {
51
+ ctx.reverse("home");
52
+ ctx.reverse("blog.post", { slug: "hello" });
53
+ // @ts-expect-error - dot-local name; no include() scope to resolve it
54
+ ctx.reverse(".home");
55
+ // @ts-expect-error - unknown global name is rejected
56
+ ctx.reverse("nope");
57
+ }
58
+ void responseReverse;
59
+
60
+ function middlewareReverse(ctx: MiddlewareContext): void {
61
+ ctx.reverse("blog.post", { slug: "hello" });
62
+ // @ts-expect-error - dot-local name; no include() scope to resolve it
63
+ ctx.reverse(".home");
64
+ }
65
+ void middlewareReverse;
66
+
42
67
  // routeName narrows to the generated route names.
43
68
  type _routeName = Expect<
44
69
  Equal<DefaultRouteName, "home" | "blog.post" | "search">
@@ -3,10 +3,10 @@
3
3
  // ============================================================================
4
4
  //
5
5
  // Usage:
6
- // import { initBrowserApp, Rango } from "rsc-router/browser";
6
+ // import { initBrowserApp, Rango } from "@rangojs/router/browser";
7
7
  //
8
8
  // For React components (Link, useNavigation, etc.):
9
- // import { Link, useNavigation, useAction, href } from "rsc-router/client";
9
+ // import { Link, useNavigation, useAction, href } from "@rangojs/router/client";
10
10
  //
11
11
  // ============================================================================
12
12
 
@@ -18,6 +18,7 @@ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
18
18
  import {
19
19
  handleNavigationStart,
20
20
  handleNavigationEnd,
21
+ handleTraversalStart,
21
22
  ensureHistoryKey,
22
23
  } from "./scroll-restoration.js";
23
24
 
@@ -496,6 +497,7 @@ export function createNavigationBridge(
496
497
  async handlePopstate(): Promise<void> {
497
498
  // Abort any pending navigation to prevent race conditions
498
499
  eventController.abortNavigation();
500
+ handleTraversalStart();
499
501
 
500
502
  const url = window.location.href;
501
503
 
@@ -698,7 +700,7 @@ export function createNavigationBridge(
698
700
  tx.handle.signal,
699
701
  tx.with({
700
702
  url,
701
- replace: true,
703
+ traversal: true,
702
704
  scroll: false,
703
705
  intercept: isIntercept,
704
706
  interceptSourceUrl,
@@ -771,9 +773,6 @@ export function createNavigationBridge(
771
773
  "[Browser] Page restored from bfcache, resetting navigation state",
772
774
  );
773
775
  eventController.abortNavigation();
774
- // pagehide flips scrollRestoration to "auto" for bfcache compat;
775
- // restore "manual" so the router controls scroll on SPA navigations.
776
- window.history.scrollRestoration = "manual";
777
776
  }
778
777
  };
779
778
 
@@ -11,7 +11,11 @@ import {
11
11
  } from "./scroll-restoration.js";
12
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
- import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
14
+ import {
15
+ buildHistoryState,
16
+ mergeLocationState,
17
+ pushHistoryWithIdx,
18
+ } from "./history-state.js";
15
19
 
16
20
  export { resolveNavigationState } from "./history-state.js";
17
21
 
@@ -49,6 +53,12 @@ interface CommitOptions {
49
53
  cacheOnly?: boolean;
50
54
  /** Server-set location state to merge into history.pushState */
51
55
  serverState?: Record<string, unknown>;
56
+ /**
57
+ * Back/forward: history is already at this entry. Its state (scroll key,
58
+ * location state, idx) stays as the browser restored it; serverState is
59
+ * merged in. The page left was saved by handleTraversalStart.
60
+ */
61
+ traversal?: boolean;
52
62
  }
53
63
 
54
64
  /**
@@ -131,6 +141,7 @@ export function createNavigationTransaction(
131
141
  interceptSourceUrl,
132
142
  cacheOnly,
133
143
  serverState,
144
+ traversal,
134
145
  } = opts;
135
146
 
136
147
  const parsedUrl = new URL(url, window.location.origin);
@@ -145,7 +156,7 @@ export function createNavigationTransaction(
145
156
  return { scroll: false };
146
157
  }
147
158
 
148
- handleNavigationStart();
159
+ if (!traversal) handleNavigationStart();
149
160
 
150
161
  store.setSegmentIds(segmentIds);
151
162
  store.setCurrentUrl(url);
@@ -162,6 +173,15 @@ export function createNavigationTransaction(
162
173
  return { scroll: false };
163
174
  }
164
175
 
176
+ if (traversal) {
177
+ if (serverState && Object.keys(serverState).length > 0) {
178
+ mergeLocationState(serverState);
179
+ }
180
+ handle.complete(parsedUrl);
181
+ debugLog("[Browser] Traversal committed, historyKey:", historyKey);
182
+ return { scroll };
183
+ }
184
+
165
185
  const historyState = buildHistoryState(
166
186
  opts.state,
167
187
  { intercept, sourceUrl: interceptSourceUrl },
@@ -43,7 +43,9 @@ function toScrollPayload(
43
43
  return { enabled: scroll !== false ? scroll : false };
44
44
  }
45
45
 
46
- function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
46
+ export function shouldStartViewTransition(
47
+ segments: ResolvedSegment[],
48
+ ): boolean {
47
49
  let hasIntercept = false;
48
50
  let hasTransition = false;
49
51
  for (const s of segments) {
@@ -53,6 +55,29 @@ function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
53
55
  return !hasIntercept && hasTransition;
54
56
  }
55
57
 
58
+ /**
59
+ * Transition commit. Every held commit announces the loader streams the
60
+ * committed tree is still receiving (loader-store.ts announcePendingStreams)
61
+ * so held useLoader readers pin isLoading:true until this transition lands;
62
+ * settled/cached segments make it a no-op. Urgent commits call onUpdate
63
+ * directly: nothing is held there, a reader of a pending stream suspends to
64
+ * its fallback. Also used by renderRoute's navigate() (testing/render-route.tsx).
65
+ */
66
+ export function commitInTransition(
67
+ onUpdate: UpdateSubscriber,
68
+ segments: readonly ResolvedSegment[],
69
+ update: NavigationUpdate,
70
+ transitionTypes: readonly string[],
71
+ ): void {
72
+ startTransition(() => {
73
+ loaderStore.announcePendingStreams(segments);
74
+ if (addTransitionType) {
75
+ for (const type of transitionTypes) addTransitionType(type);
76
+ }
77
+ onUpdate(update);
78
+ });
79
+ }
80
+
56
81
  /**
57
82
  * Configuration for creating a partial updater
58
83
  */
@@ -141,28 +166,6 @@ export function createPartialUpdater(
141
166
  return cached?.segments || [];
142
167
  }
143
168
 
144
- /**
145
- * Transition commit. Every held commit announces the loader streams the
146
- * committed tree is still receiving (loader-store.ts announcePendingStreams)
147
- * so held useLoader readers pin isLoading:true until this transition lands;
148
- * settled/cached segments make it a no-op. Urgent commits call onUpdate
149
- * directly: nothing is held there, a reader of a pending stream suspends to
150
- * its fallback.
151
- */
152
- function commitInTransition(
153
- segments: readonly ResolvedSegment[],
154
- update: NavigationUpdate,
155
- transitionTypes: readonly string[],
156
- ): void {
157
- startTransition(() => {
158
- loaderStore.announcePendingStreams(segments);
159
- if (addTransitionType) {
160
- for (const type of transitionTypes) addTransitionType(type);
161
- }
162
- onUpdate(update);
163
- });
164
- }
165
-
166
169
  async function fetchPartialUpdate(
167
170
  targetUrl: string,
168
171
  segmentIds: string[] | undefined,
@@ -374,7 +377,9 @@ export function createPartialUpdater(
374
377
  };
375
378
 
376
379
  if (shouldStartViewTransition(existingSegments)) {
377
- commitInTransition(existingSegments, cachedUpdate, ["navigation"]);
380
+ commitInTransition(onUpdate, existingSegments, cachedUpdate, [
381
+ "navigation",
382
+ ]);
378
383
  } else {
379
384
  onUpdate(cachedUpdate);
380
385
  }
@@ -614,12 +619,14 @@ export function createPartialUpdater(
614
619
  };
615
620
  if (mode.type === "action" || mode.type === "stale-revalidation") {
616
621
  commitInTransition(
622
+ onUpdate,
617
623
  reconciled.mainSegments,
618
624
  update,
619
625
  hasTransition ? ["action"] : [],
620
626
  );
621
627
  } else if (hasTransition) {
622
628
  commitInTransition(
629
+ onUpdate,
623
630
  reconciled.mainSegments,
624
631
  update,
625
632
  optimisticPresented
@@ -650,6 +657,7 @@ export function createPartialUpdater(
650
657
  // segment reconciles in place (clientGroup key), so a read that still
651
658
  // suspends must hold the presented content, not flash a fallback.
652
659
  commitInTransition(
660
+ onUpdate,
653
661
  reconciled.mainSegments,
654
662
  update,
655
663
  optimisticPresented ? [OPTIMISTIC_COMMIT_TRANSITION_TYPE] : [],
@@ -713,18 +721,20 @@ export function createPartialUpdater(
713
721
  return;
714
722
  }
715
723
  commitInTransition(
724
+ onUpdate,
716
725
  segments,
717
726
  fullUpdate,
718
727
  fullHasTransition ? ["action"] : [],
719
728
  );
720
729
  } else if (mode.type === "action") {
721
730
  commitInTransition(
731
+ onUpdate,
722
732
  segments,
723
733
  fullUpdate,
724
734
  fullHasTransition ? ["action"] : [],
725
735
  );
726
736
  } else if (fullHasTransition) {
727
- commitInTransition(segments, fullUpdate, ["navigation"]);
737
+ commitInTransition(onUpdate, segments, fullUpdate, ["navigation"]);
728
738
  } else {
729
739
  onUpdate(fullUpdate);
730
740
  }
@@ -4,7 +4,7 @@ import { useEffect } from "react";
4
4
  import { initScrollRestoration } from "../scroll-restoration.js";
5
5
 
6
6
  /**
7
- * Props for ScrollRestoration component
7
+ * Props for `<Html.ScrollRestoration />`.
8
8
  */
9
9
  export interface ScrollRestorationProps {
10
10
  /**
@@ -25,10 +25,10 @@ export interface ScrollRestorationProps {
25
25
  * const byPathname = (location) => location.pathname;
26
26
  *
27
27
  * // Restore based on pathname (same URL = same scroll)
28
- * <ScrollRestoration getKey={byPathname} />
28
+ * <Html.ScrollRestoration getKey={byPathname} />
29
29
  *
30
30
  * // Restore based on unique history entry (default)
31
- * // <ScrollRestoration /> — omit getKey to use location.key
31
+ * // <Html.ScrollRestoration /> — omit getKey to use location.key
32
32
  * ```
33
33
  */
34
34
  getKey?: (location: {
@@ -40,7 +40,7 @@ export interface ScrollRestorationProps {
40
40
  }
41
41
 
42
42
  /**
43
- * ScrollRestoration component
43
+ * Public as `Html.ScrollRestoration` (`@rangojs/router/client`).
44
44
  *
45
45
  * Enables scroll position restoration across navigations:
46
46
  * - Saves scroll positions to sessionStorage
@@ -57,7 +57,7 @@ export interface ScrollRestorationProps {
57
57
  * return (
58
58
  * <html>
59
59
  * <body>
60
- * <ScrollRestoration />
60
+ * <Html.ScrollRestoration />
61
61
  * {children}
62
62
  * </body>
63
63
  * </html>
@@ -78,7 +78,7 @@ export function ScrollRestoration({ getKey }: ScrollRestorationProps) {
78
78
  /**
79
79
  * Hook to initialize scroll restoration
80
80
  *
81
- * Alternative to the ScrollRestoration component for more control.
81
+ * Alternative to `<Html.ScrollRestoration />` for more control.
82
82
  *
83
83
  * @example
84
84
  * ```tsx
@@ -655,7 +655,7 @@ export interface RangoProps {}
655
655
  *
656
656
  * @example
657
657
  * ```tsx
658
- * import { initBrowserApp, Rango } from "rsc-router/browser";
658
+ * import { initBrowserApp, Rango } from "@rangojs/router/browser";
659
659
  * import { rscStream } from "rsc-html-stream/client";
660
660
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
661
661
  *
@@ -72,6 +72,13 @@ type GetScrollKeyFunction = (location: {
72
72
 
73
73
  let customGetKey: GetScrollKeyFunction | null = null;
74
74
 
75
+ /**
76
+ * Scroll key of the entry on screen. At popstate history.state already belongs
77
+ * to the destination, so this is the only way to save the page being left
78
+ * under its own key (see handleTraversalStart).
79
+ */
80
+ let displayedKey: string | null = null;
81
+
75
82
  /**
76
83
  * Generate a unique key for the current history entry.
77
84
  * Uses history.state.key if available, otherwise generates and stores a new one.
@@ -138,7 +145,7 @@ export function initScrollRestoration(options?: {
138
145
  }
139
146
 
140
147
  // Ensure current history entry has a key
141
- getHistoryStateKey();
148
+ trackDisplayedEntry();
142
149
 
143
150
  // Save scroll positions on pagehide (before leaving/refreshing)
144
151
  const handlePageHide = () => {
@@ -148,7 +155,14 @@ export function initScrollRestoration(options?: {
148
155
  window.history.scrollRestoration = "auto";
149
156
  };
150
157
 
158
+ // A bfcache restore keeps the "auto" set on pagehide; take scroll back.
159
+ // Only here: without <Html.ScrollRestoration> the browser owns scroll.
160
+ const handlePageShow = (event: PageTransitionEvent) => {
161
+ if (event.persisted) window.history.scrollRestoration = "manual";
162
+ };
163
+
151
164
  window.addEventListener("pagehide", handlePageHide);
165
+ window.addEventListener("pageshow", handlePageShow);
152
166
 
153
167
  if (IS_BROWSER_DEBUG) {
154
168
  debugLog(
@@ -160,10 +174,12 @@ export function initScrollRestoration(options?: {
160
174
  return () => {
161
175
  cancelScrollRestorationPolling();
162
176
  window.removeEventListener("pagehide", handlePageHide);
177
+ window.removeEventListener("pageshow", handlePageShow);
163
178
  window.history.scrollRestoration = "auto";
164
179
  initialized = false;
165
180
  savedScrollPositions = {};
166
181
  scrollKeyOrder = [];
182
+ displayedKey = null;
167
183
  };
168
184
  }
169
185
 
@@ -172,8 +188,10 @@ export function initScrollRestoration(options?: {
172
188
  * Maintains bounded size by evicting oldest entries when the limit is exceeded.
173
189
  */
174
190
  export function saveCurrentScrollPosition(): void {
175
- const key = getScrollKey();
191
+ saveScrollPosition(getScrollKey(), window.scrollY);
192
+ }
176
193
 
194
+ function saveScrollPosition(key: string, y: number): void {
177
195
  // If this key already exists, remove it from its current position
178
196
  // in the order array so it can be re-appended at the end (most recent).
179
197
  const existingIndex = scrollKeyOrder.indexOf(key);
@@ -181,7 +199,7 @@ export function saveCurrentScrollPosition(): void {
181
199
  scrollKeyOrder.splice(existingIndex, 1);
182
200
  }
183
201
 
184
- savedScrollPositions[key] = window.scrollY;
202
+ savedScrollPositions[key] = y;
185
203
  scrollKeyOrder.push(key);
186
204
 
187
205
  // Evict oldest entries if we exceed the limit
@@ -354,6 +372,23 @@ export function handleNavigationStart(): void {
354
372
  saveCurrentScrollPosition();
355
373
  }
356
374
 
375
+ /**
376
+ * Handle scroll for a back/forward traversal, at popstate before anything
377
+ * renders: save the page being left under ITS key (history has already moved,
378
+ * so saveCurrentScrollPosition would use the destination's key) and track the
379
+ * destination as the displayed entry.
380
+ */
381
+ export function handleTraversalStart(): void {
382
+ if (!initialized) return;
383
+ const leftKey = displayedKey;
384
+ trackDisplayedEntry();
385
+ // A key shared with the destination (a getKey that collapses entries, e.g.
386
+ // by pathname) keeps the destination's saved position.
387
+ if (leftKey !== null && leftKey !== displayedKey) {
388
+ saveScrollPosition(leftKey, window.scrollY);
389
+ }
390
+ }
391
+
357
392
  /**
358
393
  * Handle scroll after navigation completes.
359
394
  * @param options.restore - If true, restore saved position (for popstate)
@@ -376,10 +411,17 @@ export function handleNavigationEnd(options: {
376
411
  // But basic scroll-to-top and hash scrolling work without it — this
377
412
  // matters during cross-app navigation where ScrollRestoration unmounts
378
413
  // and remounts, creating a brief window where initialized is false.
379
- if (restore && initialized) {
380
- if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
414
+ if (restore) {
415
+ if (
416
+ initialized &&
417
+ restoreScrollPosition({ retryIfStreaming: true, isStreaming })
418
+ ) {
381
419
  return;
382
420
  }
421
+ // An "auto" entry (no <Html.ScrollRestoration>, or created before it mounted)
422
+ // is restored by the browser. A scrollTo here races that restore, and
423
+ // when it runs second the page lands at the top.
424
+ if (window.history.scrollRestoration === "auto") return;
383
425
  // Fall through to hash or top if no saved position
384
426
  }
385
427
 
@@ -410,5 +452,10 @@ export function handleNavigationEnd(options: {
410
452
  * Call this after changing history to ensure new entry has a key.
411
453
  */
412
454
  export function ensureHistoryKey(): void {
413
- getHistoryStateKey();
455
+ trackDisplayedEntry();
456
+ }
457
+
458
+ /** Mints the current entry's key if missing and records it as displayed. */
459
+ function trackDisplayedEntry(): void {
460
+ displayedKey = getScrollKey();
414
461
  }
@@ -26,19 +26,52 @@
26
26
  import { AsyncLocalStorage } from "node:async_hooks";
27
27
  import { _setCacheExecScopeProbe } from "./taint.js";
28
28
 
29
- const cacheExecStorage = new AsyncLocalStorage<true>();
29
+ /**
30
+ * One "use cache" execution. Identity matters: the execution's handle capture
31
+ * (cache-runtime.ts) records only pushes from its own chain, including cached
32
+ * functions it calls (`parent` links them), not concurrent pushes into the
33
+ * same request store.
34
+ */
35
+ export interface CacheExecScope {
36
+ readonly parent?: CacheExecScope;
37
+ }
38
+
39
+ const cacheExecStorage = new AsyncLocalStorage<CacheExecScope>();
30
40
 
31
41
  /**
32
42
  * Run fn with the "use cache" execution scope active. Continuations spawned
33
43
  * from fn's synchronous kickoff inherit the scope; parallel chains do not.
44
+ * Pass `scope` (createCacheExecScope) to test membership with
45
+ * isInCacheExecChain.
34
46
  */
35
- export function runWithCacheExecScope<T>(fn: () => T): T {
36
- return cacheExecStorage.run(true, fn);
47
+ export function runWithCacheExecScope<T>(
48
+ fn: () => T,
49
+ scope: CacheExecScope = createCacheExecScope(),
50
+ ): T {
51
+ return cacheExecStorage.run(scope, fn);
52
+ }
53
+
54
+ /** A scope nested in the calling chain's current one, if any. */
55
+ export function createCacheExecScope(): CacheExecScope {
56
+ return { parent: cacheExecStorage.getStore() };
37
57
  }
38
58
 
39
59
  /** True when the calling async chain is inside a "use cache" body. */
40
60
  export function isInsideCacheExecScope(): boolean {
41
- return cacheExecStorage.getStore() === true;
61
+ return cacheExecStorage.getStore() !== undefined;
62
+ }
63
+
64
+ /** The calling chain's innermost "use cache" scope, if any. */
65
+ export function getCacheExecScope(): CacheExecScope | undefined {
66
+ return cacheExecStorage.getStore();
67
+ }
68
+
69
+ /** True when the calling async chain runs inside `scope`, at any depth. */
70
+ export function isInCacheExecChain(scope: CacheExecScope): boolean {
71
+ for (let s = cacheExecStorage.getStore(); s; s = s.parent) {
72
+ if (s === scope) return true;
73
+ }
74
+ return false;
42
75
  }
43
76
 
44
77
  // Wire the ambient ctx-method guards (assertNotInsideCacheExec in taint.ts)
@@ -20,12 +20,7 @@ import type { SearchParamsFilter } from "./search-params-filter.js";
20
20
  * slot; an allowlist keeps the router's own params out of the key while leaving
21
21
  * consumer `__` params intact.
22
22
  */
23
- const RESERVED_SEARCH_PARAMS = new Set([
24
- "__no_cache",
25
- "__rsc",
26
- "__html",
27
- "__prerender_collect",
28
- ]);
23
+ const RESERVED_SEARCH_PARAMS = new Set(["__no_cache", "__rsc", "__html"]);
29
24
 
30
25
  function isReservedSearchParam(key: string): boolean {
31
26
  return key.startsWith("_rsc") || RESERVED_SEARCH_PARAMS.has(key);